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/Controllers/CatchingMissionDetailController.cs b/SnakeAid.Api/Controllers/CatchingMissionDetailController.cs new file mode 100644 index 00000000..6fdf42cf --- /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/catchingmission/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/MonitoringController.cs b/SnakeAid.Api/Controllers/MonitoringController.cs new file mode 100644 index 00000000..125ac938 --- /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..64729011 --- /dev/null +++ b/SnakeAid.Api/Controllers/RescueDemoController.cs @@ -0,0 +1,665 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.SignalR; +using Microsoft.EntityFrameworkCore; +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; +using SnakeAid.Repository.Interfaces; +using SnakeAid.Service.Interfaces; + +namespace SnakeAid.Api.Controllers +{ + /// + /// 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] + public class RescueDemoController : ControllerBase + { + private readonly IHubContext _hubContext; + private readonly 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 Demo Data Management + + /// + /// Seed demo users and rescuers into database + /// + [HttpPost("seed")] + public async Task SeedDemoData() + { + var success = await _demoDataSeeder.SeedDemoDataAsync(); + if (!success) + { + return BadRequest("Failed to seed demo data. Check if data already exists or see logs."); + } + + 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" } + } + }); + } + + /// + /// Clean up all demo data (incidents, sessions, requests, missions, users) + /// + [HttpPost("cleanup")] + public async Task CleanupDemoData() + { + var success = await _demoDataSeeder.CleanupDemoDataAsync(); + if (!success) + { + return BadRequest("Failed to cleanup demo data. See logs for details."); + } + + _currentDemoIncidentId = null; + + await _hubContext.Clients.All.SendAsync("DemoDataCleanedUp", new { Message = "Demo data cleaned up" }); + + return Ok(new { message = "Demo data cleaned up successfully" }); + } + + /// + /// Get demo data status + /// + [HttpGet("status")] + public async Task GetDemoStatus() + { + var status = await _demoDataSeeder.GetStatusAsync(); + return Ok(new + { + status, + currentIncidentId = _currentDemoIncidentId, + connectedRescuers = SignalRRescueNotificationService.ConnectedRescuers.Keys.ToList() + }); + } + + #endregion + + #region User Actions (Using Real Services) + + /// + /// 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 + { + // Check if demo data is seeded + var status = await _demoDataSeeder.GetStatusAsync(); + if (!status.IsSeeded) + { + return BadRequest("Demo data not seeded. Call POST /api/rescuedemo/seed first."); + } + + // Step 1: Create incident using REAL service (matches SnakebiteIncidentController line 55) + var request = new CreateIncidentRequest + { + Lat = dto.Lat, + Lng = dto.Lng + }; + + var response = await _incidentService.CreateIncidentAsync(request, DemoDataSeeder.DEMO_USER_ID); + _currentDemoIncidentId = response.Id; + + _logger.LogInformation("Demo incident created: {IncidentId}", response.Id); + + // Step 2: Start rescue session and broadcast to rescuers (matches line 58) + var rescueResult = await _incidentService.StartRescueAsync(response.Id); + + // Combine response data (matches line 61-65) + response.SessionId = rescueResult.SessionId; + response.SessionNumber = rescueResult.SessionNumber; + response.RadiusKm = rescueResult.RadiusKm; + response.RescuersPinged = rescueResult.RescuersPinged; + + _logger.LogInformation("Rescue session started: SessionId={SessionId}, Radius={Radius}km, Rescuers={Count}", + rescueResult.SessionId, rescueResult.RadiusKm, rescueResult.RescuersPinged); + + // Notify all clients + await _hubContext.Clients.All.SendAsync("IncidentCreated", new + { + 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" + }); + + 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." + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to create demo incident"); + return BadRequest(ex.Message); + } + } + + /// + /// 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("incident/{incidentId}/trigger")] + public async Task TriggerRescue(Guid incidentId) + { + try + { + // Call REAL service + var response = await _incidentService.TriggerRescueAsync(incidentId); + + _logger.LogInformation("Rescue triggered for incident {IncidentId}, session {SessionId}", + incidentId, response.SessionId); + + 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." + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to trigger rescue"); + return BadRequest(ex.Message); + } + } + + /// + /// 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 + { + var response = await _incidentService.RaiseSessionRangeAsync(new RaiseSessionRangeRequest + { + IncidentId = incidentId + }); + + _logger.LogInformation("Range raised for incident {IncidentId}, new session {SessionId}", + incidentId, response.SessionId); + + return Ok(new + { + sessionId = response.SessionId, + sessionNumber = response.SessionNumber, + radiusKm = response.RadiusKm, + rescuersPinged = response.RescuersPinged, + message = $"Range expanded to {response.RadiusKm}km! {response.RescuersPinged} new rescuers notified." + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to raise range"); + return BadRequest(ex.Message); + } + } + + /// + /// 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("incident/{incidentId}/cancel")] + public async Task CancelIncident(Guid incidentId) + { + try + { + var response = await _incidentService.CancelIncidentAsync(incidentId); + + _logger.LogInformation("Incident {IncidentId} cancelled", incidentId); + + if (incidentId == _currentDemoIncidentId) + { + _currentDemoIncidentId = null; + } + + return Ok(new + { + incidentId = response.Id, + message = "Incident cancelled successfully. All sessions and requests terminated." + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to cancel incident"); + return BadRequest(ex.Message); + } + } + + #endregion + + #region Rescuer Actions (Using Real Services) + + /// + /// 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("request/{requestId}/accept")] + public async Task AcceptRequest(Guid requestId, [FromQuery] Guid rescuerId) + { + try + { + // 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, mission?.Id); + + return Ok(new + { + requestId, + 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(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 }); + } + } + + #endregion + + #region Query APIs + + /// + /// Get current demo incident details (from real database) + /// + [HttpGet("incident/current")] + public async Task GetCurrentIncident() + { + if (_currentDemoIncidentId == null) + { + return NotFound("No active demo incident"); + } + + try + { + var incident = await _incidentService.GetDetailIncidentAsync(_currentDemoIncidentId.Value); + return Ok(incident); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get incident details"); + return NotFound(ex.Message); + } + } + + /// + /// Get comprehensive monitoring data for current incident (incident + sessions + requests + mission) + /// + [HttpGet("incident/monitor")] + public async Task GetIncidentMonitoring() + { + 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 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 && activeMissionStatuses.Contains(m.Status)) + .OrderByDescending(m => m.CreatedAt) + .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 + { + 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 + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get incident monitoring data"); + return StatusCode(500, new { error = ex.Message }); + } + } + + /// + /// Get all demo rescuers with connection status + /// + [HttpGet("rescuers")] + public IActionResult GetRescuers() + { + var rescuers = new[] + { + 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 } + }; + + var result = rescuers.Select(r => new + { + r.id, + r.name, + r.distanceKm, + isConnected = SignalRRescueNotificationService.ConnectedRescuers.ContainsKey(r.id.ToString()) + }); + + return Ok(result); + } + + /// + /// Monitor background service sessions with real-time timeout tracking + /// + [HttpGet("sessions/monitor")] + public IActionResult GetSessionMonitoring() + { + var monitoringInfo = _timeoutService.GetMonitoringInfo(); + var (totalSessions, expiredCount, pendingCount) = _timeoutService.GetQueueStatus(); + + return Ok(new + { + summary = new + { + totalTracked = totalSessions, + expired = expiredCount, + pending = pendingCount, + healthy = _timeoutService.IsHealthy() + }, + sessions = monitoringInfo.Select(s => new + { + 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 DTOs + + public class CreateIncidentDto + { + public double Lat { get; set; } = 10.762622; + public double Lng { get; set; } = 106.660172; + public string? SymptomsReport { get; set; } + } + + #endregion +} diff --git a/SnakeAid.Api/Controllers/RescueMissionController.cs b/SnakeAid.Api/Controllers/RescueMissionController.cs new file mode 100644 index 00000000..3510bc06 --- /dev/null +++ b/SnakeAid.Api/Controllers/RescueMissionController.cs @@ -0,0 +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; + +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; + } + + // 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. + + Valid status transitions: + - Preparing → EnRoute, Cancelled + - EnRoute → RescuerArrived, MissionAborted + - RescuerArrived → MissionCompleted, MissionUncompleted, MissionAborted + + 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!"; + + 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")] + [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")] + [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 + /// 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 - 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 - 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.")); + } + } +} diff --git a/SnakeAid.Api/Controllers/SnakeCatchingMissionController.cs b/SnakeAid.Api/Controllers/SnakeCatchingMissionController.cs new file mode 100644 index 00000000..08921586 --- /dev/null +++ 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/snakecatching/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 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")] + [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 completed.")); + } + } +} diff --git a/SnakeAid.Api/Controllers/SnakeCatchingRequestController.cs b/SnakeAid.Api/Controllers/SnakeCatchingRequestController.cs new file mode 100644 index 00000000..01c6a7c4 --- /dev/null +++ b/SnakeAid.Api/Controllers/SnakeCatchingRequestController.cs @@ -0,0 +1,123 @@ +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/snakecatching/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; + } + + /// + /// 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 + /// + [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.")); + } + + /// + /// 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.")); + } + + /// + /// 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.Api/Controllers/SnakebiteIncidentController.cs b/SnakeAid.Api/Controllers/SnakebiteIncidentController.cs index 24bdd7b7..a5fcff0a 100644 --- a/SnakeAid.Api/Controllers/SnakebiteIncidentController.cs +++ b/SnakeAid.Api/Controllers/SnakebiteIncidentController.cs @@ -18,15 +18,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; } /// @@ -41,9 +44,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 Ok(ApiResponseBuilder.BuildSuccessResponse(result, "Snakebite Incident created successfully!")); + + // 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); } /// @@ -66,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) { @@ -87,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/DI/DependencyInjection.cs b/SnakeAid.Api/DI/DependencyInjection.cs index dd6045fa..f86b8e07 100644 --- a/SnakeAid.Api/DI/DependencyInjection.cs +++ b/SnakeAid.Api/DI/DependencyInjection.cs @@ -99,6 +99,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 new file mode 100644 index 00000000..52cc0cb2 --- /dev/null +++ b/SnakeAid.Api/Hubs/RescuerHub.cs @@ -0,0 +1,184 @@ +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; +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 + public static ConcurrentDictionary ConnectedRescuers => SignalRRescueNotificationService.ConnectedRescuers; + + public RescuerHub( + IRescueRequestSessionService sessionService, + IUnitOfWork unitOfWork, + ILogger logger) + { + _sessionService = sessionService; + _unitOfWork = unitOfWork; + _logger = logger; + } + + /// + /// Khi rescuer connect và join để nhận requests + /// + 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 + { + 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 + }); + } + } + + + 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 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.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() + { + 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..b78d2439 --- /dev/null +++ b/SnakeAid.Api/Pages/Demo/RescueDemo.cshtml @@ -0,0 +1,1464 @@ +@page +@model SnakeAid.Api.Pages.Demo.RescueDemoModel +@{ + ViewData["Title"] = "Rescue Demo - SignalR Test"; + Layout = null; +} + + + + + + + 🐍 SnakeAid - Rescue Demo + + + + + +
+
+

🐍 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

+
+ + + +
+ +
+
+ 👤 +

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 d76b4fec..2aac7d12 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; @@ -18,6 +19,7 @@ using Swashbuckle.AspNetCore.SwaggerUI; using System.Text.Json.Serialization; using Doppler.Extensions.Configuration; +using SnakeAid.Service.Interfaces; namespace SnakeAid.Api { @@ -106,17 +108,27 @@ 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 as singleton + // It implements both IHostedService and ISessionTimeoutService + builder.Services.AddSingleton(); + builder.Services.AddSingleton(provider => + provider.GetRequiredService()); + builder.Services.AddSingleton(provider => + provider.GetRequiredService()); + builder.Services.AddMemoryCache(); // Health checks endpoint @@ -149,6 +161,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(); @@ -160,7 +175,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 @@ -191,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()); } }); @@ -310,6 +313,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(); @@ -318,6 +323,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/Properties/launchSettings.json b/SnakeAid.Api/Properties/launchSettings.json index 97da9ec0..47888db0 100644 --- a/SnakeAid.Api/Properties/launchSettings.json +++ b/SnakeAid.Api/Properties/launchSettings.json @@ -1,4 +1,4 @@ -{ +{ "$schema": "http://json.schemastore.org/launchsettings.json", "iisSettings": { "windowsAuthentication": false, @@ -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.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 new file mode 100644 index 00000000..8ce6d6f6 --- /dev/null +++ b/SnakeAid.Api/Services/SignalRRescueNotificationService.cs @@ -0,0 +1,148 @@ +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 + { + _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) + { + 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) + { + 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/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/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/Domains/SnakeSpecies.cs b/SnakeAid.Core/Domains/SnakeSpecies.cs index 58a80abd..84a8381e 100644 --- a/SnakeAid.Core/Domains/SnakeSpecies.cs +++ b/SnakeAid.Core/Domains/SnakeSpecies.cs @@ -98,6 +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(); } } \ No newline at end of file diff --git a/SnakeAid.Core/Domains/SnakebiteIncident.cs b/SnakeAid.Core/Domains/SnakebiteIncident.cs index c6072df2..837b13de 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 @@ -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/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/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 new file mode 100644 index 00000000..4244e4dd --- /dev/null +++ b/SnakeAid.Core/Mappings/SnakeCatchingRequestMapper.cs @@ -0,0 +1,38 @@ +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() + .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/Mappings/SnakebiteIncidentMapper.cs b/SnakeAid.Core/Mappings/SnakebiteIncidentMapper.cs index 4e8cbcb2..ef947a84 100644 --- a/SnakeAid.Core/Mappings/SnakebiteIncidentMapper.cs +++ b/SnakeAid.Core/Mappings/SnakebiteIncidentMapper.cs @@ -1,5 +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; @@ -13,9 +15,18 @@ 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); + + config.NewConfig() + .Map(dest => dest.RescueMission, src => + src.Missions.OrderByDescending(m => m.CreatedAt).FirstOrDefault()); } } } 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.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/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/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/Requests/SnakeCatchingRequest/CreateSnakeCatchingRequestRequest.cs b/SnakeAid.Core/Requests/SnakeCatchingRequest/CreateSnakeCatchingRequestRequest.cs new file mode 100644 index 00000000..a5204d2e --- /dev/null +++ b/SnakeAid.Core/Requests/SnakeCatchingRequest/CreateSnakeCatchingRequestRequest.cs @@ -0,0 +1,54 @@ +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; } + + /// + /// 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/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/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.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.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/SnakeCatchingMission/SnakeCatchingMissionDetailResponse.cs b/SnakeAid.Core/Responses/SnakeCatchingMission/SnakeCatchingMissionDetailResponse.cs new file mode 100644 index 00000000..45a85138 --- /dev/null +++ b/SnakeAid.Core/Responses/SnakeCatchingMission/SnakeCatchingMissionDetailResponse.cs @@ -0,0 +1,25 @@ +using System; +using System.Collections.Generic; +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; } + public List? MissionDetails { get; set; } + } +} 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 new file mode 100644 index 00000000..815cca42 --- /dev/null +++ b/SnakeAid.Core/Responses/SnakeCatchingRequest/CreateSnakeCatchingRequestResponse.cs @@ -0,0 +1,65 @@ +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(); + public List Details { get; set; } = new List(); + } +} 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.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/DetailSnakebiteIncidentReposne.cs b/SnakeAid.Core/Responses/SnakebiteIncident/DetailSnakebiteIncidentResponse.cs similarity index 96% rename from SnakeAid.Core/Responses/SnakebiteIncident/DetailSnakebiteIncidentReposne.cs rename to SnakeAid.Core/Responses/SnakebiteIncident/DetailSnakebiteIncidentResponse.cs index bfd660c5..df1a8710 100644 --- a/SnakeAid.Core/Responses/SnakebiteIncident/DetailSnakebiteIncidentReposne.cs +++ b/SnakeAid.Core/Responses/SnakebiteIncident/DetailSnakebiteIncidentResponse.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; @@ -14,11 +15,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.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.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.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/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.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.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/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.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 ced15a13..6fe1ae78 100644 --- a/SnakeAid.Repository/Seeds/DataSeeder.cs +++ b/SnakeAid.Repository/Seeds/DataSeeder.cs @@ -270,7 +270,13 @@ public static async Task SeedAsync(SnakeAidDbContext context) 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ở." } + 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ở." } + } + } } }, @@ -301,7 +307,15 @@ public static async Task SeedAsync(SnakeAidDbContext context) 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." } + 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." } + } + } } }, @@ -334,7 +348,13 @@ public static async Task SeedAsync(SnakeAidDbContext context) 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." } + 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." } + } + } } }, @@ -361,7 +381,17 @@ public static async Task SeedAsync(SnakeAidDbContext context) { 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." } } + 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 @@ -391,7 +421,13 @@ public static async Task SeedAsync(SnakeAidDbContext context) 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." } + 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." } + } + } } }, @@ -422,7 +458,13 @@ public static async Task SeedAsync(SnakeAidDbContext context) 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." } + 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." } + } + } } }, @@ -454,7 +496,13 @@ public static async Task SeedAsync(SnakeAidDbContext context) 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." } + 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." } + } + } } }, @@ -478,14 +526,46 @@ public static async Task SeedAsync(SnakeAidDbContext context) }, 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 } + 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, - 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." } + 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." + } + } } }, @@ -514,7 +594,13 @@ public static async Task SeedAsync(SnakeAidDbContext context) 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." } + 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." } + } + } } }, @@ -543,7 +629,13 @@ public static async Task SeedAsync(SnakeAidDbContext context) 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." } + 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." } + } + } } }, @@ -569,7 +661,8 @@ public static async Task SeedAsync(SnakeAidDbContext context) 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." } } + 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 @@ -594,7 +687,7 @@ public static async Task SeedAsync(SnakeAidDbContext context) 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)." } } + 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 @@ -645,12 +738,15 @@ public static async Task SeedAsync(SnakeAidDbContext context) FirstAidGuidelineOverride = new FirstAidOverride { Mode = OverrideMode.Replace, - Steps = new List + Content = new FirstAidContent { - "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." + 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." } + } } } }, @@ -774,7 +870,7 @@ public static async Task SeedAsync(SnakeAidDbContext context) Habitat = "Đầm lầy, ruộng lúa, nơi đất ẩm" } }, - + // 22. RẮN ĐAI LỚN - Lycodon fasciatus new SnakeSpecies { @@ -812,9 +908,11 @@ public static async Task SeedAsync(SnakeAidDbContext context) }, 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ọ." + 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ọ." } + } } } }, @@ -854,9 +952,11 @@ public static async Task SeedAsync(SnakeAidDbContext context) }, 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." + 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." } + } } } } 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/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; } } } diff --git a/SnakeAid.Service/Implements/RescueMissionService.cs b/SnakeAid.Service/Implements/RescueMissionService.cs new file mode 100644 index 00000000..fde75eef --- /dev/null +++ b/SnakeAid.Service/Implements/RescueMissionService.cs @@ -0,0 +1,336 @@ +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 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 (existingActiveMission != null) + { + throw new BadRequestException($"Active mission {existingActiveMission.Id} 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) + { + 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 + ); + + 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."); + } + + // 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 + incident.Status = SnakebiteIncidentStatus.Pending; + incident.AssignedRescuerId = null; + incident.AssignedAt = null; + + _unitOfWork.GetRepository().Update(mission); + _unitOfWork.GetRepository().Update(incident); + + _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) + { + _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 + }; + } + } +} \ No newline at end of file diff --git a/SnakeAid.Service/Implements/RescueRequestSessionService.cs b/SnakeAid.Service/Implements/RescueRequestSessionService.cs index ee708e5f..54781f9e 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,799 @@ public class RescueRequestSessionService : IRescueRequestSessionService private readonly IUnitOfWork _unitOfWork; private readonly ILogger _logger; private readonly IConfiguration _configuration; + 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 const decimal DEFAULT_RESCUE_PRICE = 500000m; + private static readonly int[] RADIUS_PROGRESSION = { 10, 20, 30 }; // km + + public RescueRequestSessionService( + IUnitOfWork unitOfWork, + ILogger logger, + IConfiguration configuration, + IRescueNotificationService notificationService, + ISessionTimeoutService timeoutService) { _unitOfWork = unitOfWork; _logger = logger; _configuration = configuration; + _notificationService = notificationService; + _timeoutService = timeoutService; + } + + /// 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 () => + { + return await CreateSessionInternalAsync(incidentId, sessionNumber, radiusKm, trigger); + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating session for incident {IncidentId}: {Message}", incidentId, ex.Message); + throw; + } } - + /// Broadcast requests to rescuers - Internal version without transaction (accepts session object) + private async Task BroadcastRequestsInternalAsync(RescueRequestSession session) + { + if (session == null) + { + 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) + { + throw new NotFoundException("Incident not found."); + } + session.Incident = sessionIncident; + } + + // 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}"); + } + + 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); + + var rescuersInRadius = await GetRescuersInRadiusAsync( + incident.LocationCoordinates, + session.RadiusKm + ); + + _logger.LogInformation("Found {Count} rescuers in {RadiusKm}km radius for session {SessionId}", + rescuersInRadius.Count, session.RadiusKm, session.Id); + + if (!rescuersInRadius.Any()) + { + _logger.LogWarning("No rescuers found in {RadiusKm}km radius for session {SessionId}", + session.RadiusKm, session.Id); + return session; + } + + // 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(); + + _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 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 for OTHER incidents (preserve existing session) + if (rescuersWithPendingSet.Contains(rescuerId)) + { + _logger.LogDebug( + "Skipping rescuer {RescuerId} - already has pending request for ANOTHER incident (preserving existing session)", + rescuerId); + 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(), + SessionId = session.Id, + IncidentId = incident.Id, + RescuerId = rescuerId, + Status = RescueRequestStatus.Pending, + RequestSentAt = DateTime.UtcNow, + ExpiredAt = expiredAt, + CreatedAt = DateTime.UtcNow + }); + } + + _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); + + // 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 + 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) + { + _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; + + _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) + .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(); + + _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; + } + + + /// 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 + { + 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) + { + // 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; + } + + 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()) + { + 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); + + _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) + ).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); + throw; + } + } + + /// Accept request: Update RescuerRequest, tạo RescueMission, mark others Taken + public async Task AcceptRequestAsync(Guid requestId, Guid rescuerId) + { + try + { + 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), + asNoTracking: false + ); + + 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); + 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); + 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); + + // 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; + 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); + + // Create rescue mission inline (to avoid circular dependency with IRescueMissionService) + var incident = await _unitOfWork.GetRepository().FirstOrDefaultAsync( + predicate: i => i.Id == request.IncidentId + ); + + if (incident == null) + { + throw new NotFoundException("Incident not found."); + } + + // Verify rescuer profile exists + var rescuer = await _unitOfWork.GetRepository().FirstOrDefaultAsync( + predicate: r => r.AccountId == rescuerId + ); + + if (rescuer == null) + { + 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 + { + 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; + }); + } + 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); + } + + + /// 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 + { + 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); + + _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) + { + // 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 + 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); + 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 (use internal version - already in transaction) + var newSession = await CreateSessionInternalAsync( + incidentId, + nextSessionNumber, + nextRadius, + SessionTrigger.RadiusExpanded + ); + + // 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); + + 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, + asNoTracking: false + ); + + 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) + { + _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); + + // 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; + } + + // 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..4d1f4c1b --- /dev/null +++ b/SnakeAid.Service/Implements/SessionTimeoutBackgroundService.cs @@ -0,0 +1,384 @@ +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 + { + _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.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(); + } + catch (OperationCanceledException) + { + // 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) + { + _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) + { + 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); + } + + // Add to both collections + _sessionTimeouts[sessionId] = timeoutAt; + + if (!_timeoutSchedule.ContainsKey(timeoutAt)) + { + _timeoutSchedule[timeoutAt] = new List(); + } + _timeoutSchedule[timeoutAt].Add(sessionId); + + _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 + 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.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); + } + } + + // 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) + { + _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) + { + 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 + } + } + + // 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()) + { + _logger.LogDebug("[SessionTimeout] No expired sessions found at this check"); + return; // No expired sessions + } + + _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.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("[SessionTimeout] ✅ Successfully processed timeout for session {SessionId}", sessionId); + successCount++; + } + catch (Exception ex) + { + _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); + } + + + + /// 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) + { + _logger.LogDebug("[SessionTimeout] No sessions in schedule, using default delay: {Delay}s", DEFAULT_DELAY.TotalSeconds); + // No sessions scheduled, use default delay + return DEFAULT_DELAY; + } + + // Get earliest timeout + var earliestTimeout = _timeoutSchedule.Keys.First(); + var now = DateTime.UtcNow; + + 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; + } + + var calculatedDelay = earliestTimeout - now; + + // 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; + } + } + + + /// Reset current timer to reschedule with new timeout + 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 + } + } + + + /// Remove session from schedule collections + 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); + } + } + + + /// 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 + } + + /// 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..."); + await base.StopAsync(cancellationToken); + } + } +} \ No newline at end of file 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/SnakeCatchingMissionService.cs b/SnakeAid.Service/Implements/SnakeCatchingMissionService.cs new file mode 100644 index 00000000..05268a7c --- /dev/null +++ b/SnakeAid.Service/Implements/SnakeCatchingMissionService.cs @@ -0,0 +1,228 @@ +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.Linq; +using System.Threading.Tasks; + +namespace SnakeAid.Service.Implements +{ + public class SnakeCatchingMissionService : ISnakeCatchingMissionService + { + private readonly IUnitOfWork _unitOfWork; + private readonly ILogger _logger; + + private decimal basePrice = 500000; + + 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; + } + } + + 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 mission first + _unitOfWork.GetRepository().Update(mission); + + // Update SnakeCatchingRequest to Finished + var catchingRequest = await _unitOfWork.GetRepository() + .FirstOrDefaultAsync(predicate: r => r.Id == mission.SnakeCatchingRequestId); + + if (catchingRequest != null) + { + catchingRequest.Status = RequestStatus.Finished; + _unitOfWork.GetRepository().Update(catchingRequest); + } + + 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/Implements/SnakeCatchingRequestService.cs b/SnakeAid.Service/Implements/SnakeCatchingRequestService.cs new file mode 100644 index 00000000..542b2da6 --- /dev/null +++ b/SnakeAid.Service/Implements/SnakeCatchingRequestService.cs @@ -0,0 +1,404 @@ +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 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()) + { + 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) + .Include(r => r.Details) + .ThenInclude(d => d.SnakeSpecies) + ); + + 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" + }; + } + + 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) + .Include(r => r.Details) + .ThenInclude(d => d.SnakeSpecies) + ); + + 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; + } + } + + 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; + } + } + + 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/Implements/SnakebiteIncidentService.cs b/SnakeAid.Service/Implements/SnakebiteIncidentService.cs index 71f01a0b..4271ebba 100644 --- a/SnakeAid.Service/Implements/SnakebiteIncidentService.cs +++ b/SnakeAid.Service/Implements/SnakebiteIncidentService.cs @@ -20,12 +20,21 @@ 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) @@ -33,23 +42,24 @@ public async Task CancelIncidentAsync(Guid incidentId) try { 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."); - } - // Validate incident status - only allow cancelling for Pending incidents - if (existingIncident.Status != SnakebiteIncidentStatus.Pending && existingIncident.Status != SnakebiteIncidentStatus.Assigned) { - throw new BadRequestException($"Cannot cancel incident with status: {existingIncident.Status}"); - } - existingIncident.Status = SnakebiteIncidentStatus.Cancelled; - _unitOfWork.GetRepository().Update(existingIncident); - await _unitOfWork.CommitAsync(); - return existingIncident.Adapt(); + var existingIncident = await _unitOfWork.GetRepository().FirstOrDefaultAsync( + predicate: s => s.Id == incidentId + ); + if (existingIncident == null) + { + throw new NotFoundException("Snakebite incident not found."); + } + // 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}"); + } + existingIncident.Status = SnakebiteIncidentStatus.Cancelled; + _unitOfWork.GetRepository().Update(existingIncident); + await _unitOfWork.CommitAsync(); + var responseData = existingIncident.Adapt(); + return responseData; }); } catch (Exception ex) @@ -90,36 +100,21 @@ public async Task CreateIncidentAsync(CreateIncidentRequ 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 responseData; - }); + }); + } catch (Exception ex) { @@ -138,128 +133,122 @@ 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) + ); + + if (existingIncident == null) + { + throw new NotFoundException("Snakebite incident not found."); + } - // Close current session as Failed - var currentSession = existingIncident.Sessions - .FirstOrDefault(s => s.SessionNumber == existingIncident.CurrentSessionNumber); + // 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}"); + } - if (currentSession != null) - { - currentSession.Status = SessionStatus.Failed; - currentSession.CompletedAt = DateTime.UtcNow; - _unitOfWork.GetRepository().Update(currentSession); - } + // Close current session as Failed + var currentSession = existingIncident.Sessions + .FirstOrDefault(s => s.SessionNumber == existingIncident.CurrentSessionNumber); - // Check if maximum sessions reached (max 3 sessions) - if (existingIncident.CurrentSessionNumber >= 3) - { - existingIncident.Status = SnakebiteIncidentStatus.NoRescuerFound; - existingIncident.LastSessionAt = DateTime.UtcNow; - _unitOfWork.GetRepository().Update(existingIncident); - await _unitOfWork.CommitAsync(); + if (currentSession != null) + { + currentSession.Status = SessionStatus.Failed; + currentSession.CompletedAt = DateTime.UtcNow; + _unitOfWork.GetRepository().Update(currentSession); + } - throw new BadRequestException("Maximum session range expansions reached (3 sessions). No rescuers found in area."); - } + // 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(); + + 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; + existingIncident.CurrentRadiusKm = newRadius; + existingIncident.LastSessionAt = DateTime.UtcNow; - // 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 - }; + // 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); - await _unitOfWork.GetRepository().InsertAsync(newSession); - await _unitOfWork.CommitAsync(); + await _unitOfWork.CommitAsync(); - // Reload sessions collection from DB to ensure consistency and proper order - await _unitOfWork.Context.Entry(existingIncident) - .Collection(i => i.Sessions) - .LoadAsync(); + // Reload sessions collection from DB to ensure consistency and proper order + await _unitOfWork.Context.Entry(existingIncident) + .Collection(i => i.Sessions) + .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(); + var responseData = existingIncident.Adapt(); + responseData.Sessions = existingIncident.Sessions + .OrderBy(s => s.SessionNumber) + .Select(s => s.Adapt()) + .ToList(); - return responseData; + return responseData; }); } catch (Exception ex) { - _logger.LogError(ex, "Error raising session range: {Message}", ex.Message); + _logger.LogError(ex, "Error retrieving snakebite incident details: {Message}", ex.Message); throw; } } - public async Task GetDetailIncidentAsync(Guid incidentId) + 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 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.Missions) + .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) @@ -279,84 +268,85 @@ 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."); - } + 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; + // Calculate elapsed time from incident occurrence + var currentTime = DateTime.UtcNow; + var elapsedMinutes = existingIncident.IncidentOccurredAt.HasValue + ? (int)(currentTime - existingIncident.IncidentOccurredAt.Value).TotalMinutes + : 0; - // Collect symptom descriptions and calculate severity - var symptomDescriptions = new List(); - var coreSymptomScores = new List(); - var modifierSymptomScores = new List(); + // Collect symptom descriptions and calculate severity + var symptomDescriptions = new List(); + var coreSymptomScores = new List(); + var modifierSymptomScores = new List(); - foreach (var symptomId in request.SymptomIdList) - { - var symptom = await _unitOfWork.GetRepository().FirstOrDefaultAsync( - predicate: s => s.Id == symptomId - ); - - 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(); - return existingIncident.Adapt(); + var responseData = existingIncident.Adapt(); + return responseData; }); } catch (Exception ex) @@ -386,5 +376,131 @@ private int CalculateScoreByElapsedTime(List timeScoreList, int 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; + } + } + + + /// 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/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); + } +} 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..5b40e817 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 removed: Rescuers cannot reject due to emergency nature - requests timeout automatically + // 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..e1263700 --- /dev/null +++ b/SnakeAid.Service/Interfaces/ISessionTimeoutService.cs @@ -0,0 +1,35 @@ +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(); + + /// 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 diff --git a/SnakeAid.Service/Interfaces/ISnakeCatchingMissionService.cs b/SnakeAid.Service/Interfaces/ISnakeCatchingMissionService.cs new file mode 100644 index 00000000..3fc63a0e --- /dev/null +++ b/SnakeAid.Service/Interfaces/ISnakeCatchingMissionService.cs @@ -0,0 +1,14 @@ +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); + Task CompleteMissionAsync(Guid rescuerId, Guid missionId, UpdateMissionStatusRequest request); + } +} diff --git a/SnakeAid.Service/Interfaces/ISnakeCatchingRequestService.cs b/SnakeAid.Service/Interfaces/ISnakeCatchingRequestService.cs new file mode 100644 index 00000000..703c70de --- /dev/null +++ b/SnakeAid.Service/Interfaces/ISnakeCatchingRequestService.cs @@ -0,0 +1,22 @@ +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); + + Task AcceptSnakeCatchingRequestAsync(Guid rescuerId, Guid requestId); + + Task GetDetailAsync(Guid requestId); + + Task> GetAllRequestAsync(); + + } +} diff --git a/SnakeAid.Service/Interfaces/ISnakebiteIncidentService.cs b/SnakeAid.Service/Interfaces/ISnakebiteIncidentService.cs index 97a53c48..67238cd3 100644 --- a/SnakeAid.Service/Interfaces/ISnakebiteIncidentService.cs +++ b/SnakeAid.Service/Interfaces/ISnakebiteIncidentService.cs @@ -1,4 +1,4 @@ -using SnakeAid.Core.Requests; +using SnakeAid.Core.Requests; using SnakeAid.Core.Requests.RescueRequestSession; using SnakeAid.Core.Requests.SnakebiteIncident; using SnakeAid.Core.Responses.SnakebiteIncident; @@ -9,12 +9,22 @@ public interface ISnakebiteIncidentService { Task CreateIncidentAsync(CreateIncidentRequest request, Guid userId); - Task GetDetailIncidentAsync(Guid incidentId); + Task GetDetailIncidentAsync(Guid incidentId); Task RaiseSessionRangeAsync(RaiseSessionRangeRequest request); Task UpdateSymptomReportAsync(Guid incidentId, UpdateSymptomReportRequest request); 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); + } } 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 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