diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..e4437b39 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,39 @@ +# Git +.git +.gitignore +.gitattributes +.gitmodules + +# Visual Studio / VS Code +.vs +.vscode +**/*.user +**/*.suo +**/*.launch +*.sln.docstates + +# Build results +**/bin/ +**/obj/ +**/TestResults/ + +# Docker +Dockerfile +docker-compose* +.dockerignore + +# Automation +Jenkinsfile + +# Documentation +SnakeAid.Docs/ +*.md + +# Settings (Local overrides) +appsettings.*.json +!appsettings.json +!appsettings.Development.json + +# Temporary files +**/*.swp +**/*.tmp diff --git a/SnakeAid.Api/Controllers/AuthController.cs b/SnakeAid.Api/Controllers/AuthController.cs index 9eac06bc..444cbe30 100644 --- a/SnakeAid.Api/Controllers/AuthController.cs +++ b/SnakeAid.Api/Controllers/AuthController.cs @@ -42,7 +42,7 @@ [FromBody] RegisterRequest request ) { var result = await _authService.RegisterAsync(request, role); - return StatusCode(result.StatusCode, result); + return Ok(ApiResponseBuilder.BuildSuccessResponse(result, "Registration successful.")); } /// @@ -56,7 +56,7 @@ [FromBody] RegisterRequest request public async Task Login([FromBody] LoginRequest request) { var result = await _authService.LoginAsync(request); - return StatusCode(result.StatusCode, result); + return Ok(ApiResponseBuilder.BuildSuccessResponse(result, "Login successful.")); } /// @@ -70,7 +70,7 @@ public async Task Login([FromBody] LoginRequest request) public async Task RefreshToken([FromBody] RefreshTokenRequest request) { var result = await _authService.RefreshTokenAsync(request); - return StatusCode(result.StatusCode, result); + return Ok(ApiResponseBuilder.BuildSuccessResponse(result, "Token refreshed successfully.")); } /// @@ -85,7 +85,7 @@ public async Task RefreshToken([FromBody] RefreshTokenRequest req public async Task GoogleLogin([FromBody] GoogleLoginRequest request) { var result = await _authService.GoogleLoginAsync(request); - return StatusCode(result.StatusCode, result); + return Ok(ApiResponseBuilder.BuildSuccessResponse(result, "Google login successful.")); } /// @@ -100,8 +100,8 @@ public async Task GoogleLogin([FromBody] GoogleLoginRequest reque public async Task Logout() { var userId = GetCurrentUserId(); - var result = await _authService.LogoutAsync(userId); - return StatusCode(result.StatusCode, result); + await _authService.LogoutAsync(userId); + return Ok(ApiResponseBuilder.BuildSuccessResponse("Logged out successfully.")); } /// @@ -116,7 +116,7 @@ public async Task Logout() public async Task VerifyAccount([FromBody] VerifyAccountRequest request) { var result = await _authService.VerifyAccountAsync(request); - return StatusCode(result.StatusCode, result); + return Ok(ApiResponseBuilder.BuildSuccessResponse(result, result.Message)); } /// diff --git a/SnakeAid.Api/Controllers/FirstAidGuidelineController.cs b/SnakeAid.Api/Controllers/FirstAidGuidelineController.cs index e7cdb96d..c4db8620 100644 --- a/SnakeAid.Api/Controllers/FirstAidGuidelineController.cs +++ b/SnakeAid.Api/Controllers/FirstAidGuidelineController.cs @@ -39,7 +39,7 @@ public FirstAidGuidelineController( public async Task CreateFirstAidGuideline([FromBody] CreateFirstAidGuidelineRequest request) { var result = await _guidelineService.CreateFirstAidGuidelineAsync(request); - return StatusCode(result.StatusCode, result); + return Ok(ApiResponseBuilder.BuildSuccessResponse(result, "First aid guideline created successfully!")); } /// @@ -52,7 +52,7 @@ public async Task CreateFirstAidGuideline([FromBody] CreateFirstA public async Task GetFirstAidGuidelineById(int id) { var result = await _guidelineService.GetFirstAidGuidelineByIdAsync(id); - return StatusCode(result.StatusCode, result); + return Ok(ApiResponseBuilder.BuildSuccessResponse(result)); } /// @@ -64,7 +64,7 @@ public async Task GetFirstAidGuidelineById(int id) public async Task FilterFirstAidGuidelines([FromQuery] GetFirstAidGuidelineRequest request) { var result = await _guidelineService.FilterFirstAidGuidelinesAsync(request); - return StatusCode(result.StatusCode, result); + return Ok(ApiResponseBuilder.BuildSuccessResponse(result)); } /// @@ -76,7 +76,7 @@ public async Task FilterFirstAidGuidelines([FromQuery] GetFirstAi public async Task GetAllFirstAidGuideline() { var result = await _guidelineService.GetAllFirstAidGuidelineAsync(); - return StatusCode(result.StatusCode, result); + return Ok(ApiResponseBuilder.BuildSuccessResponse(result)); } /// @@ -89,7 +89,7 @@ public async Task GetAllFirstAidGuideline() public async Task GetFirstAidGuidelinesBySnakeSpecies(int snakeSpeciesId) { var result = await _guidelineService.GetFirstAidGuidelinesBySnakeSpeciesIdAsync(snakeSpeciesId); - return StatusCode(result.StatusCode, result); + return Ok(ApiResponseBuilder.BuildSuccessResponse(result)); } /// @@ -106,7 +106,7 @@ public async Task GetFirstAidGuidelinesBySnakeSpecies(int snakeSp public async Task UpdateFirstAidGuideline(int id, [FromBody] UpdateFirstAidGuidelineRequest request) { var result = await _guidelineService.UpdateFirstAidGuidelineAsync(id, request); - return StatusCode(result.StatusCode, result); + return Ok(ApiResponseBuilder.BuildSuccessResponse(result, "First aid guideline updated successfully!")); } /// @@ -120,8 +120,8 @@ public async Task UpdateFirstAidGuideline(int id, [FromBody] Upda [SwaggerResponse(404, "Guideline not found")] public async Task DeleteFirstAidGuideline(int id) { - var result = await _guidelineService.DeleteFirstAidGuidelineAsync(id); - return StatusCode(result.StatusCode, result); + await _guidelineService.DeleteFirstAidGuidelineAsync(id); + return Ok(ApiResponseBuilder.BuildSuccessResponse("First aid guideline deleted successfully!")); } } } diff --git a/SnakeAid.Api/Controllers/MediaController.cs b/SnakeAid.Api/Controllers/MediaController.cs index 0f5a216a..322fc442 100644 --- a/SnakeAid.Api/Controllers/MediaController.cs +++ b/SnakeAid.Api/Controllers/MediaController.cs @@ -72,6 +72,6 @@ public async Task UploadReportMedia( CancellationToken ct = default) { var result = await _mediaService.UploadReportMediaAsync(request, type, purpose, User, ct); - return StatusCode(result.StatusCode, result); + return Ok(ApiResponseBuilder.BuildSuccessResponse(result, "Report media uploaded successfully.")); } } diff --git a/SnakeAid.Api/Controllers/SnakeDetectionController.cs b/SnakeAid.Api/Controllers/SnakeDetectionController.cs index abd23ab1..480c414e 100644 --- a/SnakeAid.Api/Controllers/SnakeDetectionController.cs +++ b/SnakeAid.Api/Controllers/SnakeDetectionController.cs @@ -31,7 +31,7 @@ public SnakeDetectionController( /// /// Detect snake species from uploaded ReportMedia /// - /// Detection request with ReportMediaId + /// ID of the ReportMedia entity /// Cancellation token /// Detection result with species info and confidence [HttpPost("detect/{reportMediaId:guid}")] @@ -45,7 +45,7 @@ public SnakeDetectionController( public async Task Detect([FromRoute] Guid reportMediaId, CancellationToken ct = default) { var result = await _snakeAIService.DetectFromReportMediaAsync(reportMediaId, ct); - return StatusCode(result.StatusCode, result); + return Ok(ApiResponseBuilder.BuildSuccessResponse(result, "Snake detection completed successfully.")); } /// @@ -63,6 +63,6 @@ public async Task Detect([FromRoute] Guid reportMediaId, Cancella public async Task GetDetectionResult(Guid id, CancellationToken ct = default) { var result = await _snakeAIService.GetRecognitionResultAsync(id, ct); - return StatusCode(result.StatusCode, result); + return Ok(ApiResponseBuilder.BuildSuccessResponse(result, "Recognition result retrieved successfully.")); } } diff --git a/SnakeAid.Api/Controllers/SnakebiteIncidentController.cs b/SnakeAid.Api/Controllers/SnakebiteIncidentController.cs index 44cc6ee8..24bdd7b7 100644 --- a/SnakeAid.Api/Controllers/SnakebiteIncidentController.cs +++ b/SnakeAid.Api/Controllers/SnakebiteIncidentController.cs @@ -6,9 +6,7 @@ using SnakeAid.Core.Requests; using SnakeAid.Core.Requests.RescueRequestSession; using SnakeAid.Core.Requests.SnakebiteIncident; -using SnakeAid.Core.Responses.Auth; using SnakeAid.Core.Responses.SnakebiteIncident; -using SnakeAid.Service.Implements; using SnakeAid.Service.Interfaces; using Swashbuckle.AspNetCore.Annotations; @@ -45,7 +43,7 @@ public async Task CreateSnakebiteIncident([FromBody] CreateIncide // Create incident and first rescue request session var result = await _incidentService.CreateIncidentAsync(request, userId); - return StatusCode(result.StatusCode, result); + return Ok(ApiResponseBuilder.BuildSuccessResponse(result, "Snakebite Incident created successfully!")); } /// @@ -60,7 +58,20 @@ public async Task RaiseSessionRange(Guid incidentId) { var request = new RaiseSessionRangeRequest { IncidentId = incidentId }; var result = await _incidentService.RaiseSessionRangeAsync(request); - return StatusCode(result.StatusCode, result); + return Ok(ApiResponseBuilder.BuildSuccessResponse(result, $"Session range expanded successfully. New radius: {result.CurrentRadiusKm}km")); + } + + /// + /// Get detailed information about a specific snakebite incident + /// + [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(404, "Incident not found")] + public async Task GetIncidentDetail(Guid incidentId) + { + var result = await _incidentService.GetDetailIncidentAsync(incidentId); + return Ok(ApiResponseBuilder.BuildSuccessResponse(result, "Incident details retrieved successfully!")); } /// @@ -74,7 +85,7 @@ public async Task RaiseSessionRange(Guid incidentId) public async Task UpdateSymptomReport(Guid incidentId, [FromBody] UpdateSymptomReportRequest request) { var result = await _incidentService.UpdateSymptomReportAsync(incidentId, request); - return StatusCode(result.StatusCode, result); + return Ok(ApiResponseBuilder.BuildSuccessResponse(result, "Symptom report updated successfully!")); } } } diff --git a/SnakeAid.Api/Controllers/SymptomConfigController.cs b/SnakeAid.Api/Controllers/SymptomConfigController.cs index 7725f56c..295294c3 100644 --- a/SnakeAid.Api/Controllers/SymptomConfigController.cs +++ b/SnakeAid.Api/Controllers/SymptomConfigController.cs @@ -40,7 +40,7 @@ public SymptomConfigController( public async Task CreateSymptomConfig([FromBody] CreateSymptomConfigRequest request) { var result = await _symptomConfigService.CreateSymptomConfigAsync(request); - return StatusCode(result.StatusCode, result); + return Ok(ApiResponseBuilder.BuildSuccessResponse(result, "Symptom configuration created successfully!")); } /// @@ -53,7 +53,7 @@ public async Task CreateSymptomConfig([FromBody] CreateSymptomCon public async Task GetSymptomConfigById(int id) { var result = await _symptomConfigService.GetSymptomConfigByIdAsync(id); - return StatusCode(result.StatusCode, result); + return Ok(ApiResponseBuilder.BuildSuccessResponse(result)); } /// @@ -65,7 +65,7 @@ public async Task GetSymptomConfigById(int id) public async Task FilterSymptomConfigs([FromQuery] GetSymptomConfigRequest request) { var result = await _symptomConfigService.FilterSymptomConfigsAsync(request); - return StatusCode(result.StatusCode, result); + return Ok(ApiResponseBuilder.BuildSuccessResponse(result)); } /// @@ -77,7 +77,7 @@ public async Task FilterSymptomConfigs([FromQuery] GetSymptomConf public async Task GetAllSymptomConfig() { var result = await _symptomConfigService.GetAllSymptomConfigAsync(); - return StatusCode(result.StatusCode, result); + return Ok(ApiResponseBuilder.BuildSuccessResponse(result)); } /// @@ -89,7 +89,7 @@ public async Task GetAllSymptomConfig() public async Task GetSymptomConfigsGrouped() { var result = await _symptomConfigService.GetSymptomConfigsGroupedByKeyAsync(); - return StatusCode(result.StatusCode, result); + return Ok(ApiResponseBuilder.BuildSuccessResponse(result)); } /// @@ -106,7 +106,7 @@ public async Task GetSymptomConfigsGrouped() public async Task UpdateSymptomConfig(int id, [FromBody] UpdateSymptomConfigRequest request) { var result = await _symptomConfigService.UpdateSymptomConfigAsync(id, request); - return StatusCode(result.StatusCode, result); + return Ok(ApiResponseBuilder.BuildSuccessResponse(result, "Symptom configuration updated successfully!")); } /// @@ -120,8 +120,8 @@ public async Task UpdateSymptomConfig(int id, [FromBody] UpdateSy [SwaggerResponse(404, "Configuration not found")] public async Task DeleteSymptomConfig(int id) { - var result = await _symptomConfigService.DeleteSymptomConfigAsync(id); - return StatusCode(result.StatusCode, result); + await _symptomConfigService.DeleteSymptomConfigAsync(id); + return Ok(ApiResponseBuilder.BuildSuccessResponse("Symptom configuration deleted successfully!")); } } } diff --git a/SnakeAid.Core/Responses/CreateRescueMissionResponse.cs b/SnakeAid.Core/Responses/CreateRescueMissionResponse.cs new file mode 100644 index 00000000..faeb10e0 --- /dev/null +++ b/SnakeAid.Core/Responses/CreateRescueMissionResponse.cs @@ -0,0 +1,48 @@ +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.Responses +{ + public class CreateRescueMissionResponse + { + public Guid Id { get; set; } + + + public Guid IncidentId { get; set; } // FK to SnakebiteIncident (1-1) + + + public Guid RescuerId { get; set; } // FK to RescuerProfile + + public RescueMissionStatus Status { get; set; } = RescueMissionStatus.Preparing; + + [Column(TypeName = "numeric(18,2)")] + [Range(0, double.MaxValue)] + public decimal Price { get; set; } + + public DateTime? StartedAt { get; set; } + + public DateTime? ArrivedAt { get; set; } + + public DateTime? CompletedAt { get; set; } + + [MaxLength(2000)] + public string? Notes { get; set; } + + [MaxLength(500)] + public string? CancellationReason { get; set; } + + [Column(TypeName = "numeric(18,2)")] + [Range(0, double.MaxValue)] + public decimal? EstimatedCost { get; set; } + + [Column(TypeName = "numeric(18,2)")] + [Range(0, double.MaxValue)] + public decimal? ActualCost { get; set; } + } +} diff --git a/SnakeAid.Core/Responses/ListRescueRequestResponse.cs b/SnakeAid.Core/Responses/ListRescueRequestResponse.cs new file mode 100644 index 00000000..bfda5e6b --- /dev/null +++ b/SnakeAid.Core/Responses/ListRescueRequestResponse.cs @@ -0,0 +1,33 @@ +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.Responses +{ + public class ListRescueRequestResponse + { + public Guid Id { get; set; } + + + public Guid SessionId { get; set; } // FK to RescueRequestSession (quan trọng!) + + + public Guid IncidentId { get; set; } // FK to SnakebiteIncident + + + public Guid RescuerId { get; set; } // FK to RescuerProfile + + public RescueRequestStatus Status { get; set; } = RescueRequestStatus.Pending; + + public DateTime RequestSentAt { get; set; } = DateTime.UtcNow; + + public DateTime? ResponseAt { get; set; } // Khi rescuer accept/reject + + public DateTime ExpiredAt { get; set; } // Auto-expire after X minutes + } +} diff --git a/SnakeAid.Core/Responses/MemberProfile/BriefMemberProfileRespone.cs b/SnakeAid.Core/Responses/MemberProfile/BriefMemberProfileRespone.cs new file mode 100644 index 00000000..6bf9b0fd --- /dev/null +++ b/SnakeAid.Core/Responses/MemberProfile/BriefMemberProfileRespone.cs @@ -0,0 +1,33 @@ +using SnakeAid.Core.Domains; +using SnakeAid.Core.Responses.Auth; +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.MemberProfile +{ + public class BriefMemberProfileRespone + { + + public Guid AccountId { get; set; } + + [Range(0.0, 5.0)] + public float Rating { get; set; } + + [Range(0, int.MaxValue)] + public int RatingCount { get; set; } + + [Column(TypeName = "jsonb")] + public List EmergencyContacts { get; set; } = new List(); + + public bool HasUnderlyingDisease { get; set; } + + + // Navigation properties + public UserInfo Account { get; set; } + } +} diff --git a/SnakeAid.Core/Responses/RescueRequestSession/CreateRescueRequestSessionResponse.cs b/SnakeAid.Core/Responses/RescueRequestSession/CreateRescueRequestSessionResponse.cs index 4a3919c9..742990ef 100644 --- a/SnakeAid.Core/Responses/RescueRequestSession/CreateRescueRequestSessionResponse.cs +++ b/SnakeAid.Core/Responses/RescueRequestSession/CreateRescueRequestSessionResponse.cs @@ -13,27 +13,20 @@ public class CreateRescueRequestSessionResponse { public Guid Id { get; set; } - [Required] public Guid IncidentId { get; set; } - [Required] public int SessionNumber { get; set; } // 1, 2, 3, 4, 5, 6 - [Required] public int RadiusKm { get; set; } // 5, 10, 20 - radius hiện tại đang quét - [Required] public SessionStatus Status { get; set; } = SessionStatus.Active; - [Required] public DateTime CreatedAt { get; set; } = DateTime.UtcNow; // Tracking fields - [Required] public SessionTrigger TriggerType { get; set; } = SessionTrigger.Initial; - [Required] public int RescuersPinged { get; set; } = 0; // Số lượng rescuers được ping } } diff --git a/SnakeAid.Core/Responses/RescuerProfile/BriefRescuerProfileResponse.cs b/SnakeAid.Core/Responses/RescuerProfile/BriefRescuerProfileResponse.cs new file mode 100644 index 00000000..3a0bb4a0 --- /dev/null +++ b/SnakeAid.Core/Responses/RescuerProfile/BriefRescuerProfileResponse.cs @@ -0,0 +1,46 @@ +using NetTopologySuite.Geometries; +using SnakeAid.Core.Domains; +using SnakeAid.Core.Responses.Auth; +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.RescuerProfile +{ + public class BriefRescuerProfileResponse + { + + public Guid AccountId { get; set; } + + [Required] + public bool IsOnline { get; set; } = false; + + [Range(0.0, 5.0)] + [Column(TypeName = "numeric(3,2)")] + public decimal Rating { get; set; } = 0; + + [Range(0, int.MaxValue)] + public int RatingCount { get; set; } = 0; + + public RescuerType Type { get; set; } = RescuerType.Emergency; + + // PostGIS Location tracking + [Column(TypeName = "geometry(Point, 4326)")] + public Point? LastLocation { get; set; } + + public DateTime? LastLocationUpdate { get; set; } + + // Statistics + public int TotalMissions { get; set; } = 0; + + public int CompletedMissions { get; set; } = 0; + + + // Navigation properties + public UserInfo Account { get; set; } + } +} diff --git a/SnakeAid.Core/Responses/SnakebiteIncident/DetailSnakebiteIncidentReposne.cs b/SnakeAid.Core/Responses/SnakebiteIncident/DetailSnakebiteIncidentReposne.cs new file mode 100644 index 00000000..bfd660c5 --- /dev/null +++ b/SnakeAid.Core/Responses/SnakebiteIncident/DetailSnakebiteIncidentReposne.cs @@ -0,0 +1,61 @@ +using NetTopologySuite.Geometries; +using SnakeAid.Core.Domains; +using SnakeAid.Core.Responses.Media; +using SnakeAid.Core.Responses.MemberProfile; +using SnakeAid.Core.Responses.RescueRequestSession; +using SnakeAid.Core.Responses.RescuerProfile; +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.SnakebiteIncident +{ + public class DetailSnakebiteIncidentReposne + { + public Guid Id { get; set; } + + + public Guid UserId { get; set; } // FK to MemberProfile + + [Column(TypeName = "geometry(Point, 4326)")] + public Point LocationCoordinates { get; set; } + + [Column(TypeName = "jsonb")] + public string? SymptomsReport { get; set; } + + public SnakebiteIncidentStatus Status { get; set; } = SnakebiteIncidentStatus.Pending; + + // Session ping info + public int CurrentSessionNumber { get; set; } = 0; // Track session hiện tại + + [Range(1, 50)] + public int CurrentRadiusKm { get; set; } = 5; // Radius hiện tại + + public DateTime? LastSessionAt { get; set; } // Tránh spam sessions + + // Assigned rescuer info + public DateTime? AssignedAt { get; set; } + + [ForeignKey(nameof(AssignedRescuer))] + public Guid? AssignedRescuerId { get; set; } // FK to assigned rescuer + + [MaxLength(500)] + public string? CancellationReason { get; set; } + + public int? SeverityLevel { get; set; } = 1; // 1-5 emergency level + + public DateTime? IncidentOccurredAt { get; set; } // Khi nào bị cắn + + // Navigation properties + public BriefMemberProfileRespone User { get; set; } + public BriefRescuerProfileResponse? AssignedRescuer { get; set; } + public List Sessions { get; set; } = new List(); + public List AllRequests { get; set; } = new List(); // Denormalized for easy query + public CreateRescueMissionResponse? RescueMission { get; set; } + public List Media { get; set; } = new List(); + } +} diff --git a/SnakeAid.Docs b/SnakeAid.Docs index d93f0855..7377ac31 160000 --- a/SnakeAid.Docs +++ b/SnakeAid.Docs @@ -1 +1 @@ -Subproject commit d93f0855c096942a1f4c90633446ac8a0bfbec0d +Subproject commit 7377ac31de0541886c28e33772eaac8df30f1bc5 diff --git a/SnakeAid.Service/Implements/AuthService.cs b/SnakeAid.Service/Implements/AuthService.cs index 160c2698..2e12385f 100644 --- a/SnakeAid.Service/Implements/AuthService.cs +++ b/SnakeAid.Service/Implements/AuthService.cs @@ -6,17 +6,15 @@ using Microsoft.IdentityModel.Tokens; using SnakeAid.Core.Domains; using SnakeAid.Core.Enums; -using SnakeAid.Core.Meta; +using SnakeAid.Core.Exceptions; using SnakeAid.Core.Requests.Auth; using SnakeAid.Core.Responses.Auth; using SnakeAid.Core.Settings; -using SnakeAid.Core.Utils; using SnakeAid.Repository.Data; using SnakeAid.Repository.Interfaces; using SnakeAid.Service.Interfaces; using System.Globalization; using System.IdentityModel.Tokens.Jwt; -using System.Net; using System.Security.Claims; using System.Security.Cryptography; using System.Text; @@ -60,14 +58,13 @@ public AuthService( #region Public Methods - public async Task> RegisterAsync(RegisterRequest request, RegisterRole? targetRole) + public async Task RegisterAsync(RegisterRequest request, RegisterRole? targetRole) { // Check if email already exists var existingUser = await _userManager.FindByEmailAsync(request.Email); if (existingUser != null) { - return ApiResponseBuilder.CreateResponse( - null, false, "Email is already in use.", HttpStatusCode.BadRequest, "EMAIL_IN_USE"); + throw new ConflictException("Email is already in use."); } //Map role @@ -78,8 +75,7 @@ public async Task> RegisterAsync(RegisterRequest reque if (!RegisterRoleMap.TryGetValue(targetRole.Value, out var role)) { - return ApiResponseBuilder.CreateResponse( - null, false, "Invalid role.", HttpStatusCode.BadRequest, "INVALID_ROLE"); + throw new BadRequestException("Invalid role."); } // Create new account @@ -99,15 +95,11 @@ public async Task> RegisterAsync(RegisterRequest reque var result = await _userManager.CreateAsync(user, request.Password); if (!result.Succeeded) { - var errors = new Dictionary - { - ["Identity"] = result.Errors.Select(e => e.Description).ToArray() - }; - return ApiResponseBuilder.CreateResponse( - null, false, "Registration failed.", HttpStatusCode.UnprocessableEntity, "VALIDATION_ERROR", errors); + var errorMessages = string.Join("; ", result.Errors.Select(e => e.Description)); + throw new BadRequestException($"Registration failed: {errorMessages}"); } - + switch (targetRole) { @@ -127,13 +119,12 @@ public async Task> RegisterAsync(RegisterRequest reque break; } - case RegisterRole.Rescuer: + case RegisterRole.Rescuer: { var rescuerRepository = _unitOfWork.GetRepository(); if (request.Type == null) { - return ApiResponseBuilder.CreateResponse( - null, false, "Rescuer registration details are required.", HttpStatusCode.BadRequest, "MISSING_RESCUER_DETAILS"); + throw new BadRequestException("Rescuer registration details are required."); } var selectedType = RescuerType.Emergency; @@ -163,13 +154,12 @@ public async Task> RegisterAsync(RegisterRequest reque break; } - case RegisterRole.Expert: + case RegisterRole.Expert: { var expertRepository = _unitOfWork.GetRepository(); if (String.IsNullOrEmpty(request.Biography)) { - return ApiResponseBuilder.CreateResponse( - null, false, "Expert registration details are required.", HttpStatusCode.BadRequest, "MISSING_EXPERT_DETAILS"); + throw new BadRequestException("Expert registration details are required."); } var expert = new ExpertProfile { @@ -188,25 +178,22 @@ public async Task> RegisterAsync(RegisterRequest reque _logger.LogInformation("User registered successfully: {Email}", request.Email); // Generate tokens - var tokens = await GenerateTokensAsync(user); - return ApiResponseBuilder.BuildSuccessResponse(tokens, "Registration successful."); + return await GenerateTokensAsync(user); } - public async Task> LoginAsync(LoginRequest request) + public async Task LoginAsync(LoginRequest request) { // Find user by email var user = await _userManager.FindByEmailAsync(request.Email); if (user == null) { - return ApiResponseBuilder.CreateResponse( - null, false, "Invalid email or password.", HttpStatusCode.Unauthorized, "INVALID_CREDENTIALS"); + throw new UnauthorizedException("Invalid email or password."); } // Check if account is active if (!user.IsActive) { - return ApiResponseBuilder.CreateResponse( - null, false, "Account is inactive.", HttpStatusCode.Forbidden, "ACCOUNT_INACTIVE"); + throw new ForbiddenException("Account is inactive."); } // Check password with lockout @@ -215,46 +202,40 @@ public async Task> LoginAsync(LoginRequest request) if (result.IsLockedOut) { _logger.LogWarning("Account locked out: {Email}", request.Email); - return ApiResponseBuilder.CreateResponse( - null, false, "Account is locked. Please try again later.", HttpStatusCode.Forbidden, "ACCOUNT_LOCKED"); + throw new ForbiddenException("Account is locked. Please try again later."); } if (!result.Succeeded) { - return ApiResponseBuilder.CreateResponse( - null, false, "Invalid email or password.", HttpStatusCode.Unauthorized, "INVALID_CREDENTIALS"); + throw new UnauthorizedException("Invalid email or password."); } _logger.LogInformation("User logged in: {Email}", request.Email); // Generate tokens - var tokens = await GenerateTokensAsync(user); - return ApiResponseBuilder.BuildSuccessResponse(tokens, "Login successful."); + return await GenerateTokensAsync(user); } - public async Task> RefreshTokenAsync(RefreshTokenRequest request) + public async Task RefreshTokenAsync(RefreshTokenRequest request) { // Find user var user = await _userManager.FindByIdAsync(request.UserId.ToString()); if (user == null) { - return ApiResponseBuilder.CreateResponse( - null, false, "Invalid refresh token.", HttpStatusCode.Unauthorized, "INVALID_TOKEN"); + throw new UnauthorizedException("Invalid refresh token."); } // Check if account is active if (!user.IsActive) { - return ApiResponseBuilder.CreateResponse( - null, false, "Account is inactive.", HttpStatusCode.Forbidden, "ACCOUNT_INACTIVE"); + throw new ForbiddenException("Account is inactive."); } // Validate refresh token var isValid = await ValidateRefreshTokenAsync(user, request.RefreshToken); if (!isValid) { - return ApiResponseBuilder.CreateResponse( - null, false, "Invalid or expired refresh token.", HttpStatusCode.Unauthorized, "INVALID_TOKEN"); + throw new UnauthorizedException("Invalid or expired refresh token."); } // Token rotation: Remove old tokens @@ -264,19 +245,17 @@ public async Task> RefreshTokenAsync(RefreshTokenReque _logger.LogInformation("Token refreshed for user: {Email}", user.Email); // Generate new tokens - var tokens = await GenerateTokensAsync(user); - return ApiResponseBuilder.BuildSuccessResponse(tokens, "Token refreshed successfully."); + return await GenerateTokensAsync(user); } - public async Task> GoogleLoginAsync(GoogleLoginRequest request) + public async Task GoogleLoginAsync(GoogleLoginRequest request) { // Get Google Client ID var clientId = _configuration["Authentication:Google:ClientId"]; if (string.IsNullOrWhiteSpace(clientId)) { _logger.LogError("Google Client ID is not configured"); - return ApiResponseBuilder.CreateResponse( - null, false, "Google authentication is not configured.", HttpStatusCode.BadRequest, "GOOGLE_CONFIG_ERROR"); + throw new BadRequestException("Google authentication is not configured."); } // Validate Google ID token @@ -292,15 +271,13 @@ public async Task> GoogleLoginAsync(GoogleLoginRequest catch (InvalidJwtException ex) { _logger.LogWarning("Invalid Google token: {Message}", ex.Message); - return ApiResponseBuilder.CreateResponse( - null, false, "Invalid Google token.", HttpStatusCode.Unauthorized, "INVALID_GOOGLE_TOKEN"); + throw new UnauthorizedException("Invalid Google token."); } // Check if email is verified if (!payload.EmailVerified) { - return ApiResponseBuilder.CreateResponse( - null, false, "Google email is not verified.", HttpStatusCode.Unauthorized, "EMAIL_NOT_VERIFIED"); + throw new UnauthorizedException("Google email is not verified."); } // Find or create user @@ -325,12 +302,8 @@ public async Task> GoogleLoginAsync(GoogleLoginRequest var createResult = await _userManager.CreateAsync(user); if (!createResult.Succeeded) { - var errors = new Dictionary - { - ["Identity"] = createResult.Errors.Select(e => e.Description).ToArray() - }; - return ApiResponseBuilder.CreateResponse( - null, false, "Failed to create account.", HttpStatusCode.UnprocessableEntity, "ACCOUNT_CREATE_FAILED", errors); + var errorMessages = string.Join("; ", createResult.Errors.Select(e => e.Description)); + throw new BadRequestException($"Failed to create account: {errorMessages}"); } _logger.LogInformation("New user created via Google: {Email}", payload.Email); @@ -339,8 +312,7 @@ public async Task> GoogleLoginAsync(GoogleLoginRequest // Check if account is active if (!user.IsActive) { - return ApiResponseBuilder.CreateResponse( - null, false, "Account is inactive.", HttpStatusCode.Forbidden, "ACCOUNT_INACTIVE"); + throw new ForbiddenException("Account is inactive."); } // Link Google login if not already linked @@ -354,16 +326,15 @@ public async Task> GoogleLoginAsync(GoogleLoginRequest _logger.LogInformation("Google login successful: {Email}", payload.Email); // Generate tokens - var tokens = await GenerateTokensAsync(user); - return ApiResponseBuilder.BuildSuccessResponse(tokens, "Google login successful."); + return await GenerateTokensAsync(user); } - public async Task> LogoutAsync(Guid userId) + public async Task LogoutAsync(Guid userId) { var user = await _userManager.FindByIdAsync(userId.ToString()); if (user == null) { - return ApiResponseBuilder.BuildNotFoundResponse("User not found."); + throw new NotFoundException("User not found."); } // Remove refresh tokens @@ -371,46 +342,32 @@ public async Task> LogoutAsync(Guid userId) await _userManager.RemoveAuthenticationTokenAsync(user, RefreshTokenProvider, RefreshTokenExpiryName); _logger.LogInformation("User logged out: {Email}", user.Email); - - return ApiResponseBuilder.BuildSuccessResponse("Logged out successfully."); } - public async Task> VerifyAccountAsync(VerifyAccountRequest request) + public async Task VerifyAccountAsync(VerifyAccountRequest request) { // Find user by email var user = await _userManager.FindByEmailAsync(request.Email); if (user == null) { - return ApiResponseBuilder.CreateResponse( - null, false, "User not found.", HttpStatusCode.NotFound, "USER_NOT_FOUND"); + throw new NotFoundException("User not found."); } // Check if already active if (user.IsActive) { - var response = new VerifyAccountResponse + return new VerifyAccountResponse { Success = true, Message = "Account is already verified and active." }; - return ApiResponseBuilder.BuildSuccessResponse(response, response.Message); } // Validate OTP var otpValidation = await _otpService.ValidateOtp(request.Email, request.Otp); if (!otpValidation.Success) { - return ApiResponseBuilder.CreateResponse( - new VerifyAccountResponse - { - Success = false, - Message = otpValidation.Message, - AuthData = null - }, - false, - otpValidation.Message, - HttpStatusCode.BadRequest, - "OTP_VALIDATION_FAILED"); + throw new BadRequestException(otpValidation.Message); } // Activate user account @@ -421,22 +378,19 @@ public async Task> VerifyAccountAsync(VerifyA if (!updateResult.Succeeded) { _logger.LogError("Failed to activate user {Email}", request.Email); - return ApiResponseBuilder.CreateResponse( - null, false, "Failed to activate account.", HttpStatusCode.InternalServerError, "ACTIVATION_FAILED"); + throw new ApiException("Failed to activate account.", System.Net.HttpStatusCode.InternalServerError); } _logger.LogInformation("User account verified and activated successfully: {Email}", request.Email); // Generate tokens for immediate login var authTokens = await GenerateTokensAsync(user); - var verifyResponse = new VerifyAccountResponse + return new VerifyAccountResponse { Success = true, Message = "Account verified and activated successfully.", AuthData = authTokens }; - - return ApiResponseBuilder.BuildSuccessResponse(verifyResponse, verifyResponse.Message); } #endregion @@ -544,4 +498,3 @@ private async Task ValidateRefreshTokenAsync(Account user, string refreshT }; #endregion } - diff --git a/SnakeAid.Service/Implements/FirstAidGuidelineService.cs b/SnakeAid.Service/Implements/FirstAidGuidelineService.cs index 946e5a57..59cc3a8b 100644 --- a/SnakeAid.Service/Implements/FirstAidGuidelineService.cs +++ b/SnakeAid.Service/Implements/FirstAidGuidelineService.cs @@ -10,7 +10,6 @@ using SnakeAid.Repository.Interfaces; using SnakeAid.Service.Interfaces; using System.Linq.Expressions; -using System.Net; namespace SnakeAid.Service.Implements { @@ -20,269 +19,184 @@ public class FirstAidGuidelineService : IFirstAidGuidelineService private readonly ILogger _logger; public FirstAidGuidelineService( - IUnitOfWork unitOfWork, + IUnitOfWork unitOfWork, ILogger logger) { _unitOfWork = unitOfWork; _logger = logger; } - public async Task> CreateFirstAidGuidelineAsync(CreateFirstAidGuidelineRequest request) + public async Task CreateFirstAidGuidelineAsync(CreateFirstAidGuidelineRequest request) { - try + if (request == null) { - if (request == null) - { - throw new ArgumentNullException(nameof(request), "Request data cannot be null."); - } - - return await _unitOfWork.ExecuteInTransactionAsync(async () => - { - var jsonOptions = new System.Text.Json.JsonSerializerOptions - { - Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping, - WriteIndented = false - }; - - var guideline = new FirstAidGuideline - { - Name = request.Name, - Content = request.Content, - Type = request.Type, - Summary = request.Summary, - CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow - }; - - await _unitOfWork.GetRepository().InsertAsync(guideline); - await _unitOfWork.CommitAsync(); - - var response = guideline.Adapt(); - return ApiResponseBuilder.BuildSuccessResponse(response, "First aid guideline created successfully!"); - }); + throw new BadRequestException("Request data cannot be null."); } - catch (Exception ex) + + return await _unitOfWork.ExecuteInTransactionAsync(async () => { - _logger.LogError(ex, "Error creating first aid guideline"); - return ApiResponseBuilder.CreateResponse( - default, false, ex.Message, HttpStatusCode.BadRequest); - } + var guideline = new FirstAidGuideline + { + Name = request.Name, + Content = request.Content, + Type = request.Type, + Summary = request.Summary, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + + await _unitOfWork.GetRepository().InsertAsync(guideline); + await _unitOfWork.CommitAsync(); + + return guideline.Adapt(); + }); } - public async Task> GetFirstAidGuidelineByIdAsync(int id) + public async Task GetFirstAidGuidelineByIdAsync(int id) { - try + var guideline = await _unitOfWork.GetRepository() + .GetByIdAsync(id); + + if (guideline == null) { - var guideline = await _unitOfWork.GetRepository() - .GetByIdAsync(id); + throw new NotFoundException($"First aid guideline with ID {id} not found."); + } - if (guideline == null) - { - throw new NotFoundException($"First aid guideline with ID {id} not found."); - } + return guideline.Adapt(); + } - var response = guideline.Adapt(); - return ApiResponseBuilder.BuildSuccessResponse(response); + public async Task> FilterFirstAidGuidelinesAsync(GetFirstAidGuidelineRequest request) + { + Expression>? predicate = null; + + // Build predicate + if (!string.IsNullOrWhiteSpace(request.Name) && request.Type.HasValue) + { + predicate = g => g.Name.Contains(request.Name) && g.Type == request.Type.Value; + } + else if (!string.IsNullOrWhiteSpace(request.Name)) + { + predicate = g => g.Name.Contains(request.Name); } - catch (Exception ex) + else if (request.Type.HasValue) { - _logger.LogError(ex, "Error getting first aid guideline by ID {Id}", id); - return ApiResponseBuilder.CreateResponse( - default, false, ex.Message, HttpStatusCode.BadRequest); + predicate = g => g.Type == request.Type.Value; } + + // Get paginated data + var pagedData = await _unitOfWork.GetRepository() + .GetPagingListAsync( + predicate: predicate, + orderBy: o => o.OrderBy(g => g.Name), + page: request.PageNumber, + size: request.PageSize + ); + + return new PagedData + { + Items = pagedData.Items.Adapt>(), + Meta = pagedData.Meta + }; } - public async Task>> FilterFirstAidGuidelinesAsync(GetFirstAidGuidelineRequest request) + public async Task UpdateFirstAidGuidelineAsync(int id, UpdateFirstAidGuidelineRequest request) { - try + if (request == null) { - Expression>? predicate = null; + throw new BadRequestException("Request data cannot be null."); + } - // Build predicate - if (!string.IsNullOrWhiteSpace(request.Name) && request.Type.HasValue) + return await _unitOfWork.ExecuteInTransactionAsync(async () => + { + var guideline = await _unitOfWork.GetRepository() + .GetByIdAsync(id); + + if (guideline == null) { - predicate = g => g.Name.Contains(request.Name) && g.Type == request.Type.Value; + throw new NotFoundException($"First aid guideline with ID {id} not found."); } - else if (!string.IsNullOrWhiteSpace(request.Name)) + + // Update only provided fields + if (!string.IsNullOrWhiteSpace(request.Name)) { - predicate = g => g.Name.Contains(request.Name); + guideline.Name = request.Name; } - else if (request.Type.HasValue) + + if (request.Content != null) { - predicate = g => g.Type == request.Type.Value; + guideline.Content = request.Content; } - // Get paginated data - var pagedData = await _unitOfWork.GetRepository() - .GetPagingListAsync( - predicate: predicate, - orderBy: o => o.OrderBy(g => g.Name), - page: request.PageNumber, - size: request.PageSize - ); - - var response = new PagedData + if (request.Type.HasValue) { - Items = pagedData.Items.Adapt>(), - Meta = pagedData.Meta - }; - - return ApiResponseBuilder.BuildSuccessResponse(response); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error filtering first aid guidelines"); - return ApiResponseBuilder.CreateResponse>( - default, false, ex.Message, HttpStatusCode.BadRequest); - } - } + guideline.Type = request.Type.Value; + } - public async Task> UpdateFirstAidGuidelineAsync(int id, UpdateFirstAidGuidelineRequest request) - { - try - { - if (request == null) + if (request.Summary != null) { - throw new ArgumentNullException(nameof(request), "Request data cannot be null."); + guideline.Summary = request.Summary; } - return await _unitOfWork.ExecuteInTransactionAsync(async () => - { - var guideline = await _unitOfWork.GetRepository() - .GetByIdAsync(id); - - if (guideline == null) - { - throw new NotFoundException($"First aid guideline with ID {id} not found."); - } - - // Update only provided fields - if (!string.IsNullOrWhiteSpace(request.Name)) - { - guideline.Name = request.Name; - } - - if (request.Content != null) - { - guideline.Content = request.Content; - } - - if (request.Type.HasValue) - { - guideline.Type = request.Type.Value; - } - - if (request.Summary != null) - { - guideline.Summary = request.Summary; - } - - guideline.UpdatedAt = DateTime.UtcNow; - - _unitOfWork.GetRepository().Update(guideline); - await _unitOfWork.CommitAsync(); - - var response = guideline.Adapt(); - return ApiResponseBuilder.BuildSuccessResponse(response, "First aid guideline updated successfully!"); - }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error updating first aid guideline with ID {Id}", id); - return ApiResponseBuilder.CreateResponse( - default, false, ex.Message, HttpStatusCode.BadRequest); - } + guideline.UpdatedAt = DateTime.UtcNow; + + _unitOfWork.GetRepository().Update(guideline); + await _unitOfWork.CommitAsync(); + + return guideline.Adapt(); + }); } - public async Task> DeleteFirstAidGuidelineAsync(int id) + public async Task DeleteFirstAidGuidelineAsync(int id) { - try + await _unitOfWork.ExecuteInTransactionAsync(async () => { - return await _unitOfWork.ExecuteInTransactionAsync(async () => - { - var guideline = await _unitOfWork.GetRepository() - .GetByIdAsync(id); + var guideline = await _unitOfWork.GetRepository() + .GetByIdAsync(id); - if (guideline == null) - { - throw new NotFoundException($"First aid guideline with ID {id} not found."); - } + if (guideline == null) + { + throw new NotFoundException($"First aid guideline with ID {id} not found."); + } - _unitOfWork.GetRepository().Delete(guideline); - await _unitOfWork.CommitAsync(); + _unitOfWork.GetRepository().Delete(guideline); + await _unitOfWork.CommitAsync(); - return ApiResponseBuilder.BuildSuccessResponse(true, "First aid guideline deleted successfully!"); - }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error deleting first aid guideline with ID {Id}", id); - return ApiResponseBuilder.CreateResponse( - false, false, ex.Message, HttpStatusCode.BadRequest); - } + return true; // Just to satisfy the transaction return type + }); } - public async Task>> GetAllFirstAidGuidelineAsync() + public async Task> GetAllFirstAidGuidelineAsync() { - try - { - var guidelines = await _unitOfWork.GetRepository() - .GetListAsync(orderBy: o => o.OrderBy(g => g.Name)); + var guidelines = await _unitOfWork.GetRepository() + .GetListAsync(orderBy: o => o.OrderBy(g => g.Name)); - var response = guidelines.Adapt>(); - return ApiResponseBuilder.BuildSuccessResponse(response); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting all first aid guidelines"); - return ApiResponseBuilder.CreateResponse>( - default, false, ex.Message, HttpStatusCode.BadRequest); - } + return guidelines.Adapt>(); } - public async Task>> GetFirstAidGuidelinesBySnakeSpeciesIdAsync(int snakeSpeciesId) + public async Task> GetFirstAidGuidelinesBySnakeSpeciesIdAsync(int snakeSpeciesId) { - try + // Check if snake species exists + var snakeSpecies = await _unitOfWork.GetRepository() + .FirstOrDefaultAsync( + predicate: s => s.Id == snakeSpeciesId, + include: q => q.Include(s => s.SpeciesVenoms) + .ThenInclude(sv => sv.VenomType) + .ThenInclude(vt => vt.FirstAidGuideline) + ); + + if (snakeSpecies == null) { - // Check if snake species exists - var snakeSpecies = await _unitOfWork.GetRepository() - .FirstOrDefaultAsync( - predicate: s => s.Id == snakeSpeciesId, - include: q => q.Include(s => s.SpeciesVenoms) - .ThenInclude(sv => sv.VenomType) - .ThenInclude(vt => vt.FirstAidGuideline) - ); - - if (snakeSpecies == null) - { - throw new NotFoundException($"Snake species with ID {snakeSpeciesId} not found."); - } - - // Get all first aid guidelines from venom types - var guidelines = snakeSpecies.SpeciesVenoms - .Where(sv => sv.VenomType != null && sv.VenomType.FirstAidGuideline != null) - .Select(sv => sv.VenomType.FirstAidGuideline) - .Distinct() - .ToList(); + throw new NotFoundException($"Snake species with ID {snakeSpeciesId} not found."); + } - if (!guidelines.Any()) - { - return ApiResponseBuilder.BuildSuccessResponse( - new List(), - "No first aid guidelines found for this snake species." - ); - } + // Get all first aid guidelines from venom types + var guidelines = snakeSpecies.SpeciesVenoms + .Where(sv => sv.VenomType != null && sv.VenomType.FirstAidGuideline != null) + .Select(sv => sv.VenomType.FirstAidGuideline) + .Distinct() + .ToList(); - var response = guidelines.Adapt>(); - return ApiResponseBuilder.BuildSuccessResponse(response); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting first aid guidelines for snake species ID {SnakeSpeciesId}", snakeSpeciesId); - return ApiResponseBuilder.CreateResponse>( - default, false, ex.Message, HttpStatusCode.BadRequest); - } + return guidelines.Adapt>(); } } } - diff --git a/SnakeAid.Service/Implements/MediaService.cs b/SnakeAid.Service/Implements/MediaService.cs index 3b48d14b..0f4775c2 100644 --- a/SnakeAid.Service/Implements/MediaService.cs +++ b/SnakeAid.Service/Implements/MediaService.cs @@ -1,7 +1,6 @@ using System.Security.Claims; using Microsoft.Extensions.Logging; using SnakeAid.Core.Domains; -using SnakeAid.Core.Meta; using SnakeAid.Core.Requests.Media; using SnakeAid.Core.Responses.Media; using SnakeAid.Repository.Data; @@ -30,64 +29,56 @@ public MediaService( } /// - public async Task> UploadReportMediaAsync( + public async Task UploadReportMediaAsync( UploadReportMediaRequest request, MediaReferenceType referenceType, MediaPurpose purpose, ClaimsPrincipal user, CancellationToken ct = default) { - try - { - _logger.LogInformation("Uploading report media for reference {ReferenceId}, type: {Type}, purpose: {Purpose}", - request.ReferenceId, referenceType, purpose); + _logger.LogInformation("Uploading report media for reference {ReferenceId}, type: {Type}, purpose: {Purpose}", + request.ReferenceId, referenceType, purpose); - // Upload file to Cloudinary first - var uploadResult = await _cloudinaryService.UploadImageAsync(request.File, user, "report-media", ct); + // Upload file to Cloudinary first + var uploadResult = await _cloudinaryService.UploadImageAsync(request.File, user, "report-media", ct); - // Save media record to database - return await _unitOfWork.ExecuteInTransactionAsync(async () => + // Save media record to database + return await _unitOfWork.ExecuteInTransactionAsync(async () => + { + var reportMedia = new ReportMedia { - var reportMedia = new ReportMedia - { - Id = Guid.NewGuid(), - FileName = request.File.FileName, - MediaUrl = uploadResult.SecureUrl, - ContentType = request.File.ContentType, - FileSize = request.File.Length, - ReferenceId = request.ReferenceId, - ReferenceType = referenceType, - Purpose = purpose, - RequiresAIProcessing = purpose == MediaPurpose.SnakeIdentification, - IsProcessed = false, - CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow - }; + Id = Guid.NewGuid(), + FileName = request.File.FileName, + MediaUrl = uploadResult.SecureUrl, + ContentType = request.File.ContentType, + FileSize = request.File.Length, + ReferenceId = request.ReferenceId, + ReferenceType = referenceType, + Purpose = purpose, + RequiresAIProcessing = purpose == MediaPurpose.SnakeIdentification, + IsProcessed = false, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; - var repository = _unitOfWork.GetRepository(); - await repository.InsertAsync(reportMedia); - await _unitOfWork.CommitAsync(); + var repository = _unitOfWork.GetRepository(); + await repository.InsertAsync(reportMedia); + await _unitOfWork.CommitAsync(); - var response = new ReportMediaResponse - { - Id = reportMedia.Id, - MediaUrl = reportMedia.MediaUrl, - FileName = reportMedia.FileName, - ContentType = reportMedia.ContentType, - FileSize = reportMedia.FileSize, - ReferenceType = reportMedia.ReferenceType, - Purpose = reportMedia.Purpose, - RequiresAIProcessing = reportMedia.RequiresAIProcessing - }; + var response = new ReportMediaResponse + { + Id = reportMedia.Id, + MediaUrl = reportMedia.MediaUrl, + FileName = reportMedia.FileName, + ContentType = reportMedia.ContentType, + FileSize = reportMedia.FileSize, + ReferenceType = reportMedia.ReferenceType, + Purpose = reportMedia.Purpose, + RequiresAIProcessing = reportMedia.RequiresAIProcessing + }; - _logger.LogInformation("Successfully uploaded report media with ID: {MediaId}", reportMedia.Id); - return ApiResponseBuilder.BuildSuccessResponse(response, "Report media uploaded successfully."); - }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error uploading report media for reference {ReferenceId}", request.ReferenceId); - throw; - } + _logger.LogInformation("Successfully uploaded report media with ID: {MediaId}", reportMedia.Id); + return response; + }); } } \ No newline at end of file diff --git a/SnakeAid.Service/Implements/SnakeAIService.cs b/SnakeAid.Service/Implements/SnakeAIService.cs index 62c17b3c..d6539514 100644 --- a/SnakeAid.Service/Implements/SnakeAIService.cs +++ b/SnakeAid.Service/Implements/SnakeAIService.cs @@ -1,9 +1,8 @@ -using System.Net; using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using SnakeAid.Core.Domains; -using SnakeAid.Core.Meta; +using SnakeAid.Core.Exceptions; using SnakeAid.Core.Requests.SnakeAI; using SnakeAid.Core.Responses.SnakeDetection; using SnakeAid.Core.Settings; @@ -36,7 +35,7 @@ public SnakeAIService( } /// - public async Task> DetectAsync(string imageUrl, Guid reportMediaId, CancellationToken ct = default) + public async Task DetectAsync(string imageUrl, Guid reportMediaId, CancellationToken ct = default) { var request = new SnakeAIDetectRequest { @@ -193,7 +192,7 @@ public async Task> DetectAsync(string imageU topDetection?.ClassName ?? "none", topDetection?.Confidence ?? 0); - var response = new SnakeDetectionResponse + return new SnakeDetectionResponse { Metadata = new AiMetadata { @@ -211,8 +210,6 @@ public async Task> DetectAsync(string imageU Results = results, RecognitionResultId = savedRecognitionResult?.Id }; - - return ApiResponseBuilder.BuildSuccessResponse(response, "Snake detection completed successfully."); } catch (Exception ex) { @@ -246,12 +243,7 @@ public async Task> DetectAsync(string imageU _logger.LogError(saveEx, "Failed to save error recognition result for ReportMediaId: {MediaId}", reportMediaId); } - return ApiResponseBuilder.CreateResponse( - null, - false, - "Snake detection failed. Please try again later.", - HttpStatusCode.InternalServerError, - "DETECTION_FAILED"); + throw new ApiException("Snake detection failed. Please try again later.", System.Net.HttpStatusCode.InternalServerError); } } @@ -280,125 +272,103 @@ public async Task IsHealthyAsync() } /// - public async Task> DetectFromReportMediaAsync(Guid reportMediaId, CancellationToken ct = default) + public async Task DetectFromReportMediaAsync(Guid reportMediaId, CancellationToken ct = default) { - try + // 1. Health check + if (!await IsHealthyAsync()) { - // 1. Health check - if (!await IsHealthyAsync()) - { - _logger.LogWarning("SnakeAI service is unavailable"); - return ApiResponseBuilder.CreateResponse( - null, false, "Snake detection service is currently unavailable. Please try again later.", - System.Net.HttpStatusCode.ServiceUnavailable, "SERVICE_UNAVAILABLE"); - } - - // 2. Validate ReportMedia exists - var reportMedia = await _unitOfWork.GetRepository() - .FirstOrDefaultAsync( - predicate: m => m.Id == reportMediaId, - cancellationToken: ct); + _logger.LogWarning("SnakeAI server is unavailable"); + throw new ApiException("Snake detection server is currently unavailable. Please try again later.", + System.Net.HttpStatusCode.ServiceUnavailable); + } - if (reportMedia == null) - { - _logger.LogWarning("ReportMedia not found: {MediaId}", reportMediaId); - return ApiResponseBuilder.CreateResponse( - null, false, "ReportMedia not found.", - System.Net.HttpStatusCode.NotFound, "NOT_FOUND"); - } + // 2. Validate ReportMedia exists + var reportMedia = await _unitOfWork.GetRepository() + .FirstOrDefaultAsync( + predicate: m => m.Id == reportMediaId, + cancellationToken: ct); - // 3. Call detection with imageUrl - return await DetectAsync(reportMedia.MediaUrl, reportMediaId, ct); - } - catch (Exception ex) + if (reportMedia == null) { - _logger.LogError(ex, "Error in DetectFromReportMediaAsync for ReportMediaId: {MediaId}", reportMediaId); - throw; + _logger.LogWarning("ReportMedia not found: {MediaId}", reportMediaId); + throw new NotFoundException("ReportMedia not found."); } + + // 3. Call detection with imageUrl + return await DetectAsync(reportMedia.MediaUrl, reportMediaId, ct); } /// - public async Task> GetRecognitionResultAsync(Guid recognitionResultId, CancellationToken ct = default) + public async Task GetRecognitionResultAsync(Guid recognitionResultId, CancellationToken ct = default) { - try + var recognitionResult = await _unitOfWork.GetRepository() + .FirstOrDefaultAsync( + predicate: r => r.Id == recognitionResultId, + include: query => query + .Include(r => r.ReportMedia) + .Include(r => r.AIModel) + .Include(r => r.DetectedSpecies) + .ThenInclude(s => s.SpeciesVenoms) + .ThenInclude(sv => sv.VenomType) + .ThenInclude(v => v.FirstAidGuideline), // Include for Fallback + cancellationToken: ct); + + if (recognitionResult == null) { - var recognitionResult = await _unitOfWork.GetRepository() - .FirstOrDefaultAsync( - predicate: r => r.Id == recognitionResultId, - include: query => query - .Include(r => r.ReportMedia) - .Include(r => r.AIModel) - .Include(r => r.DetectedSpecies) - .ThenInclude(s => s.SpeciesVenoms) - .ThenInclude(sv => sv.VenomType) - .ThenInclude(v => v.FirstAidGuideline), // Include for Fallback - cancellationToken: ct); + _logger.LogWarning("Recognition result not found: {ResultId}", recognitionResultId); + throw new NotFoundException("Recognition result not found."); + } - if (recognitionResult == null) + // Restore species logic if available + if (recognitionResult.DetectedSpecies != null) + { + var species = recognitionResult.DetectedSpecies; + // -- FALLBACK LOGIC -- + if (species.FirstAidGuidelineOverride == null && species.SpeciesVenoms.Any()) { - _logger.LogWarning("Recognition result not found: {ResultId}", recognitionResultId); - return ApiResponseBuilder.CreateResponse( - null, false, "Recognition result not found.", - System.Net.HttpStatusCode.NotFound, "NOT_FOUND"); - } + var venomWithGuide = species.SpeciesVenoms + .Select(sv => sv.VenomType) + .FirstOrDefault(v => v.FirstAidGuideline != null); - // Restore species logic if available - if (recognitionResult.DetectedSpecies != null) - { - var species = recognitionResult.DetectedSpecies; - // -- FALLBACK LOGIC -- - if (species.FirstAidGuidelineOverride == null && species.SpeciesVenoms.Any()) + if (venomWithGuide != null) { - var venomWithGuide = species.SpeciesVenoms - .Select(sv => sv.VenomType) - .FirstOrDefault(v => v.FirstAidGuideline != null); - - if (venomWithGuide != null) + species.FirstAidGuidelineOverride = new FirstAidOverride { - species.FirstAidGuidelineOverride = new FirstAidOverride - { - Mode = OverrideMode.Append, - Steps = venomWithGuide.FirstAidGuideline.Content?.Steps?.Select(s => s.Text).ToList() ?? new List() - }; - } + Mode = OverrideMode.Append, + Steps = venomWithGuide.FirstAidGuideline.Content?.Steps?.Select(s => s.Text).ToList() ?? new List() + }; } } + } + + _logger.LogInformation("Retrieved recognition result: {ResultId}", recognitionResultId); - // Build response from saved data (matching V3 strict structure) - var response = new SnakeDetectionResponse + // Build response from saved data (matching V3 strict structure) + return new SnakeDetectionResponse + { + Metadata = new AiMetadata { - Metadata = new AiMetadata - { - ModelVersion = recognitionResult.AIModel?.Version, - ImageWidth = 0, // Not stored - ImageHeight = 0, - DetectionCount = 1, - Warnings = null - }, - RecognitionResultId = recognitionResult.Id, - Results = new List + ModelVersion = recognitionResult.AIModel?.Version, + ImageWidth = 0, // Not stored + ImageHeight = 0, + DetectionCount = 1, + Warnings = null + }, + RecognitionResultId = recognitionResult.Id, + Results = new List + { + new DetectionResult { - new DetectionResult + Ai = new AiDetection { - Ai = new AiDetection - { - ClassId = 0, - ClassName = recognitionResult.YoloClassName, - Confidence = (float)recognitionResult.Confidence, - BBox = new SnakeBBox() // BBox data not stored efficiently yet - }, - Snake = recognitionResult.DetectedSpecies - } + ClassId = 0, + ClassName = recognitionResult.YoloClassName, + Confidence = (float)recognitionResult.Confidence, + BBox = new SnakeBBox() // BBox data not stored efficiently yet + }, + Snake = recognitionResult.DetectedSpecies } - }; - - _logger.LogInformation("Retrieved recognition result: {ResultId}", recognitionResultId); - return ApiResponseBuilder.BuildSuccessResponse(response, "Recognition result retrieved successfully."); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error retrieving recognition result: {ResultId}", recognitionResultId); - throw; - } + } + }; } } diff --git a/SnakeAid.Service/Implements/SnakebiteIncidentService.cs b/SnakeAid.Service/Implements/SnakebiteIncidentService.cs index 1eb47d57..71f01a0b 100644 --- a/SnakeAid.Service/Implements/SnakebiteIncidentService.cs +++ b/SnakeAid.Service/Implements/SnakebiteIncidentService.cs @@ -4,7 +4,6 @@ using Microsoft.Extensions.Logging; using SnakeAid.Core.Domains; using SnakeAid.Core.Exceptions; -using SnakeAid.Core.Meta; using SnakeAid.Core.Requests; using SnakeAid.Core.Requests.RescueRequestSession; using SnakeAid.Core.Requests.SnakebiteIncident; @@ -13,11 +12,6 @@ using SnakeAid.Repository.Data; using SnakeAid.Repository.Interfaces; using SnakeAid.Service.Interfaces; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; namespace SnakeAid.Service.Implements { @@ -34,29 +28,28 @@ public SnakebiteIncidentService(IUnitOfWork unitOfWork, ILogg _configuration = configuration; } - public async Task> CancelIncidentAsync(Guid incidentId) + 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) { - 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(); - var responseData = existingIncident.Adapt(); - return ApiResponseBuilder.BuildSuccessResponse(responseData, "Snakebite Incident cancelled successfully!"); + 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(); }); } catch (Exception ex) @@ -66,281 +59,309 @@ public async Task> CancelIncidentAsync(Guid } } - public async Task> CreateIncidentAsync(CreateIncidentRequest request, Guid userId) + public async Task CreateIncidentAsync(CreateIncidentRequest request, Guid userId) { try { if (request == null) { - throw new ArgumentNullException(nameof(request), "Request data cannot be null."); + throw new BadRequestException("Request data cannot be null."); } return await _unitOfWork.ExecuteInTransactionAsync(async () => - { - var existingAccount = await _unitOfWork.GetRepository().FirstOrDefaultAsync( - predicate: a => a.Id == userId, - include: m => m.Include(i => i.MemberProfile) - ); - - if (existingAccount.MemberProfile == null) - { - throw new BadRequestException("Member information could not be found for the current account."); - } + { + var existingAccount = await _unitOfWork.GetRepository().FirstOrDefaultAsync( + predicate: a => a.Id == userId, + include: m => m.Include(i => i.MemberProfile) + ); - // 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)); + if (existingAccount.MemberProfile == null) + { + throw new BadRequestException("Member information could not be found for the current account."); + } - var newIncident = new SnakebiteIncident - { - Id = Guid.NewGuid(), - UserId = existingAccount.Id, - LocationCoordinates = locationPoint, - Status = SnakebiteIncidentStatus.Pending, - CurrentSessionNumber = 1, - CurrentRadiusKm = 5, - LastSessionAt = DateTime.UtcNow, - 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(); + // 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)); - var responseData = newIncident.Adapt(); - responseData.Sessions = new List - { - firstRescueSession.Adapt() - }; + var newIncident = new SnakebiteIncident + { + Id = Guid.NewGuid(), + UserId = existingAccount.Id, + LocationCoordinates = locationPoint, + Status = SnakebiteIncidentStatus.Pending, + CurrentSessionNumber = 1, + CurrentRadiusKm = 5, + LastSessionAt = DateTime.UtcNow, + 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() + }; - return ApiResponseBuilder.BuildSuccessResponse(responseData, "Snakebite Incident created successfully!"); + return responseData; }); - } catch (Exception ex) { - _logger.LogError(ex, "Error creating snakebit incident: {Message}", ex.Message); + _logger.LogError(ex, "Error creating snakebite incident: {Message}", ex.Message); throw; } } - public async Task> RaiseSessionRangeAsync(RaiseSessionRangeRequest request) + public async Task RaiseSessionRangeAsync(RaiseSessionRangeRequest request) { try { if (request == null) { - throw new ArgumentNullException(nameof(request), "Request data cannot be null."); + throw new BadRequestException("Request data cannot be null."); } 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) { - // 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."); - } + 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}"); - } + // 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}"); + } - // Close current session as Failed - var currentSession = existingIncident.Sessions - .FirstOrDefault(s => s.SessionNumber == existingIncident.CurrentSessionNumber); - - 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(); - - throw new BadRequestException("Maximum session range expansions reached (3 sessions). No rescuers found in area."); - } + if (currentSession != null) + { + currentSession.Status = SessionStatus.Failed; + currentSession.CompletedAt = DateTime.UtcNow; + _unitOfWork.GetRepository().Update(currentSession); + } - // 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 - }; - - int newRadius = existingIncident.CurrentRadiusKm + radiusIncrement; - - - - // Update incident for new session - existingIncident.CurrentSessionNumber += 1; - existingIncident.CurrentRadiusKm = newRadius; + // Check if maximum sessions reached (max 3 sessions) + if (existingIncident.CurrentSessionNumber >= 3) + { + existingIncident.Status = SnakebiteIncidentStatus.NoRescuerFound; 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 - }; - - await _unitOfWork.GetRepository().InsertAsync(newSession); - - + _unitOfWork.GetRepository().Update(existingIncident); await _unitOfWork.CommitAsync(); - // 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(); - - return ApiResponseBuilder.BuildSuccessResponse(responseData, - $"Session range expanded successfully. New radius: {newRadius}km (Session {existingIncident.CurrentSessionNumber})"); + throw new BadRequestException("Maximum session range expansions reached (3 sessions). No rescuers found in area."); + } + + // 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 + }; + + int newRadius = existingIncident.CurrentRadiusKm + radiusIncrement; + + // Update incident for new session + existingIncident.CurrentSessionNumber += 1; + 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 + }; + + await _unitOfWork.GetRepository().InsertAsync(newSession); + await _unitOfWork.CommitAsync(); + + // 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(); + + return responseData; }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error raising session range: {Message}", ex.Message); + throw; + } + } + public async Task GetDetailIncidentAsync(Guid incidentId) + { + try + { + return await _unitOfWork.ExecuteInTransactionAsync(async () => + { + var existingIncident = await _unitOfWork.GetRepository().FirstOrDefaultAsync( + predicate: s => s.Id == incidentId, + include: query => query + .Include(i => i.User) + .Include(i => i.AssignedRescuer) + .Include(i => i.Sessions) + .Include(i => i.AllRequests) + .ThenInclude(r => r.Rescuer) + .Include(i => i.RescueMission) + .Include(i => i.Media) + ); + + if (existingIncident == null) + { + throw new NotFoundException("Snakebite incident not found."); + } + + var responseData = existingIncident.Adapt(); + + return responseData; + }); } catch (Exception ex) { - _logger.LogError(ex, "Error raising session range for incident: {Message}", ex.Message); + _logger.LogError(ex, "Error retrieving snakebite incident details: {Message}", ex.Message); throw; } } - public async Task> UpdateSymptomReportAsync(Guid incidentId, UpdateSymptomReportRequest request) + public async Task UpdateSymptomReportAsync(Guid incidentId, UpdateSymptomReportRequest request) { try { if (request == null) { - throw new ArgumentNullException(nameof(request), "Request data cannot be null."); + throw new BadRequestException("Request data cannot be null."); } + return await _unitOfWork.ExecuteInTransactionAsync(async () => + { + var existingIncident = await _unitOfWork.GetRepository().FirstOrDefaultAsync( + predicate: s => s.Id == incidentId + ); + if (existingIncident == null) { - var existingIncident = await _unitOfWork.GetRepository().FirstOrDefaultAsync( - predicate: s => s.Id == incidentId - ); - if (existingIncident == null) - { - throw new NotFoundException("Snakebite incident not found."); - } + 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 + ); - foreach (var symptomId in request.SymptomIdList) + if (symptom != null) { - var symptom = await _unitOfWork.GetRepository().FirstOrDefaultAsync( - predicate: s => s.Id == symptomId - ); - - if (symptom != null) + // Add symptom description + if (!string.IsNullOrEmpty(symptom.Description)) { - // 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); - } + symptomDescriptions.Add(symptom.Description); } - } - // Calculate severity level - // Core: take maximum score - var severityLevel = 0; - if (coreSymptomScores.Any()) - { - severityLevel = coreSymptomScores.Max(); - } + // Calculate score based on TimeScoreList + var score = CalculateScoreByElapsedTime(symptom.TimeScoreList, elapsedMinutes); - // Modifier: sum all scores - if (modifierSymptomScores.Any()) - { - severityLevel += modifierSymptomScores.Sum(); + // Categorize by symptom category + if (symptom.Category == SymptomCategory.Core) + { + coreSymptomScores.Add(score); + } + else if (symptom.Category == SymptomCategory.Modifier) + { + modifierSymptomScores.Add(score); + } } + } - if (severityLevel > 100) - severityLevel = 100; + // Calculate severity level + // Core: take maximum score + var severityLevel = 0; + if (coreSymptomScores.Any()) + { + severityLevel = coreSymptomScores.Max(); + } - // Update symptom report and severity level - var jsonOptions = new System.Text.Json.JsonSerializerOptions - { - Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping, - WriteIndented = false - }; - existingIncident.SymptomsReport = System.Text.Json.JsonSerializer.Serialize(symptomDescriptions, jsonOptions); - existingIncident.SeverityLevel = severityLevel; - _unitOfWork.GetRepository().Update(existingIncident); - await _unitOfWork.CommitAsync(); - - var responseData = existingIncident.Adapt(); - return ApiResponseBuilder.BuildSuccessResponse(responseData, "Symptom report updated successfully!"); + // Modifier: sum all scores + if (modifierSymptomScores.Any()) + { + severityLevel += modifierSymptomScores.Sum(); + } + + 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(); + + return existingIncident.Adapt(); }); } catch (Exception ex) { - _logger.LogError(ex, "Error updating symptom report for incident: {Message}", ex.Message); + _logger.LogError(ex, "Error updating symptom report: {Message}", ex.Message); throw; } } @@ -359,7 +380,7 @@ private int CalculateScoreByElapsedTime(List timeScoreList, int } // Find the TimeScorePoint where elapsedMinutes falls within MinMinutes and MaxMinutes - var matchingScore = timeScoreList.FirstOrDefault(ts => + var matchingScore = timeScoreList.FirstOrDefault(ts => elapsedMinutes >= ts.MinMinutes && elapsedMinutes <= ts.MaxMinutes ); diff --git a/SnakeAid.Service/Implements/SymptomConfigService.cs b/SnakeAid.Service/Implements/SymptomConfigService.cs index 26ebe972..8c198f96 100644 --- a/SnakeAid.Service/Implements/SymptomConfigService.cs +++ b/SnakeAid.Service/Implements/SymptomConfigService.cs @@ -11,7 +11,6 @@ using SnakeAid.Service.Interfaces; using System.Text.Json; using System.Linq.Expressions; -using System.Net; namespace SnakeAid.Service.Implements { @@ -28,348 +27,275 @@ public SymptomConfigService( _logger = logger; } - public async Task> CreateSymptomConfigAsync(CreateSymptomConfigRequest request) + public async Task CreateSymptomConfigAsync(CreateSymptomConfigRequest request) { - try + if (request == null) { - if (request == null) - { - throw new ArgumentNullException(nameof(request), "Request data cannot be null."); - } + throw new BadRequestException("Request data cannot be null."); + } - return await _unitOfWork.ExecuteInTransactionAsync(async () => + return await _unitOfWork.ExecuteInTransactionAsync(async () => + { + // Validate VenomTypeId if provided + if (request.VenomTypeId.HasValue) { - // Validate VenomTypeId if provided - if (request.VenomTypeId.HasValue) - { - var venomTypeExists = await _unitOfWork.GetRepository() - .FirstOrDefaultAsync(predicate: vt => vt.Id == request.VenomTypeId.Value); + var venomTypeExists = await _unitOfWork.GetRepository() + .FirstOrDefaultAsync(predicate: vt => vt.Id == request.VenomTypeId.Value); - if (venomTypeExists == null) - { - throw new NotFoundException($"VenomType with ID {request.VenomTypeId.Value} not found."); - } - } - - var symptomConfig = new SymptomConfig - { - GroupName = request.GroupName, - AttributeKey = request.AttributeKey, - AttributeLabel = request.AttributeLabel, - DisplayOrder = request.DisplayOrder, - Name = request.Name, - Description = request.Description, - IsCritical = request.IsCritical, - AlertMessage = request.AlertMessage, - IsActive = request.IsActive, - Category = request.Category, - TimeScoresJson = request.TimeScoreList != null && request.TimeScoreList.Any() - ? JsonSerializer.Serialize(request.TimeScoreList) - : null, - VenomTypeId = request.VenomTypeId, - CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow - }; - - await _unitOfWork.GetRepository().InsertAsync(symptomConfig); - await _unitOfWork.CommitAsync(); - - // Load VenomType if exists - if (symptomConfig.VenomTypeId.HasValue) + if (venomTypeExists == null) { - symptomConfig = await _unitOfWork.GetRepository() - .FirstOrDefaultAsync( - predicate: sc => sc.Id == symptomConfig.Id, - include: q => q.Include(sc => sc.VenomType), - asNoTracking: false - ); + throw new NotFoundException($"VenomType with ID {request.VenomTypeId.Value} not found."); } + } - var response = symptomConfig.Adapt(); - return ApiResponseBuilder.BuildSuccessResponse(response, "Symptom configuration created successfully!"); - }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error creating symptom configuration"); - return ApiResponseBuilder.CreateResponse( - default, false, ex.Message, HttpStatusCode.BadRequest); - } - } + var symptomConfig = new SymptomConfig + { + GroupName = request.GroupName, + AttributeKey = request.AttributeKey, + AttributeLabel = request.AttributeLabel, + DisplayOrder = request.DisplayOrder, + Name = request.Name, + Description = request.Description, + IsCritical = request.IsCritical, + AlertMessage = request.AlertMessage, + IsActive = request.IsActive, + Category = request.Category, + TimeScoresJson = request.TimeScoreList != null && request.TimeScoreList.Any() + ? JsonSerializer.Serialize(request.TimeScoreList) + : null, + VenomTypeId = request.VenomTypeId, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; - public async Task> GetSymptomConfigByIdAsync(int id) - { - try - { - var symptomConfig = await _unitOfWork.GetRepository() - .FirstOrDefaultAsync( - predicate: sc => sc.Id == id, - include: q => q.Include(sc => sc.VenomType) - ); + await _unitOfWork.GetRepository().InsertAsync(symptomConfig); + await _unitOfWork.CommitAsync(); - if (symptomConfig == null) + // Load VenomType if exists + if (symptomConfig.VenomTypeId.HasValue) { - throw new NotFoundException($"Symptom configuration with ID {id} not found."); + symptomConfig = await _unitOfWork.GetRepository() + .FirstOrDefaultAsync( + predicate: sc => sc.Id == symptomConfig.Id, + include: q => q.Include(sc => sc.VenomType), + asNoTracking: false + ); } - var response = symptomConfig.Adapt(); - return ApiResponseBuilder.BuildSuccessResponse(response); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting symptom configuration by ID {Id}", id); - return ApiResponseBuilder.CreateResponse( - default, false, ex.Message, HttpStatusCode.BadRequest); - } + return symptomConfig.Adapt(); + }); } - public async Task>> FilterSymptomConfigsAsync(GetSymptomConfigRequest request) + public async Task GetSymptomConfigByIdAsync(int id) { - try + var symptomConfig = await _unitOfWork.GetRepository() + .FirstOrDefaultAsync( + predicate: sc => sc.Id == id, + include: q => q.Include(sc => sc.VenomType) + ); + + if (symptomConfig == null) { - Expression>? predicate = null; - - // Build combined predicate - predicate = sc => - (string.IsNullOrWhiteSpace(request.GroupName) || sc.GroupName.Contains(request.GroupName)) && - (string.IsNullOrWhiteSpace(request.AttributeKey) || sc.AttributeKey.Contains(request.AttributeKey)) && - (string.IsNullOrWhiteSpace(request.Name) || sc.Name.Contains(request.Name)) && - (!request.Category.HasValue || sc.Category == request.Category.Value) && - (!request.IsActive.HasValue || sc.IsActive == request.IsActive.Value) && - (!request.IsCritical.HasValue || sc.IsCritical == request.IsCritical.Value) && - (!request.VenomTypeId.HasValue || sc.VenomTypeId == request.VenomTypeId.Value); - - // Get paginated data - var pagedData = await _unitOfWork.GetRepository() - .GetPagingListAsync( - predicate: predicate, - orderBy: o => o.OrderBy(sc => sc.DisplayOrder).ThenBy(sc => sc.AttributeKey).ThenBy(sc => sc.Name), - include: q => q.Include(sc => sc.VenomType), - page: request.PageNumber, - size: request.PageSize - ); + throw new NotFoundException($"Symptom configuration with ID {id} not found."); + } - var responseItems = pagedData.Items.Adapt>(); - var response = new PagedData - { - Items = responseItems, - Meta = pagedData.Meta - }; + return symptomConfig.Adapt(); + } - return ApiResponseBuilder.BuildSuccessResponse(response); - } - catch (Exception ex) + public async Task> FilterSymptomConfigsAsync(GetSymptomConfigRequest request) + { + Expression>? predicate = null; + + // Build combined predicate + predicate = sc => + (string.IsNullOrWhiteSpace(request.GroupName) || sc.GroupName.Contains(request.GroupName)) && + (string.IsNullOrWhiteSpace(request.AttributeKey) || sc.AttributeKey.Contains(request.AttributeKey)) && + (string.IsNullOrWhiteSpace(request.Name) || sc.Name.Contains(request.Name)) && + (!request.Category.HasValue || sc.Category == request.Category.Value) && + (!request.IsActive.HasValue || sc.IsActive == request.IsActive.Value) && + (!request.IsCritical.HasValue || sc.IsCritical == request.IsCritical.Value) && + (!request.VenomTypeId.HasValue || sc.VenomTypeId == request.VenomTypeId.Value); + + // Get paginated data + var pagedData = await _unitOfWork.GetRepository() + .GetPagingListAsync( + predicate: predicate, + orderBy: o => o.OrderBy(sc => sc.DisplayOrder).ThenBy(sc => sc.AttributeKey).ThenBy(sc => sc.Name), + include: q => q.Include(sc => sc.VenomType), + page: request.PageNumber, + size: request.PageSize + ); + + var responseItems = pagedData.Items.Adapt>(); + return new PagedData { - _logger.LogError(ex, "Error filtering symptom configurations"); - return ApiResponseBuilder.CreateResponse>( - default, false, ex.Message, HttpStatusCode.BadRequest); - } + Items = responseItems, + Meta = pagedData.Meta + }; } - public async Task> UpdateSymptomConfigAsync(int id, UpdateSymptomConfigRequest request) + public async Task UpdateSymptomConfigAsync(int id, UpdateSymptomConfigRequest request) { - try + if (request == null) + { + throw new BadRequestException("Request data cannot be null."); + } + + return await _unitOfWork.ExecuteInTransactionAsync(async () => { - if (request == null) + var symptomConfig = await _unitOfWork.GetRepository() + .FirstOrDefaultAsync( + predicate: sc => sc.Id == id, + include: q => q.Include(sc => sc.VenomType), + asNoTracking: false + ); + + if (symptomConfig == null) { - throw new ArgumentNullException(nameof(request), "Request data cannot be null."); + throw new NotFoundException($"Symptom configuration with ID {id} not found."); } - return await _unitOfWork.ExecuteInTransactionAsync(async () => + // Validate VenomTypeId if provided + if (request.VenomTypeId.HasValue) { - var symptomConfig = await _unitOfWork.GetRepository() - .FirstOrDefaultAsync( - predicate: sc => sc.Id == id, - include: q => q.Include(sc => sc.VenomType), - asNoTracking: false - ); + var venomTypeExists = await _unitOfWork.GetRepository() + .FirstOrDefaultAsync(predicate: vt => vt.Id == request.VenomTypeId.Value); - if (symptomConfig == null) + if (venomTypeExists == null) { - throw new NotFoundException($"Symptom configuration with ID {id} not found."); - } - - // Validate VenomTypeId if provided - if (request.VenomTypeId.HasValue) - { - var venomTypeExists = await _unitOfWork.GetRepository() - .FirstOrDefaultAsync(predicate: vt => vt.Id == request.VenomTypeId.Value); - - if (venomTypeExists == null) - { - throw new NotFoundException($"VenomType with ID {request.VenomTypeId.Value} not found."); - } + throw new NotFoundException($"VenomType with ID {request.VenomTypeId.Value} not found."); } + } - // Update only provided fields - if (!string.IsNullOrWhiteSpace(request.GroupName)) - { - symptomConfig.GroupName = request.GroupName; - } + // Update only provided fields + if (!string.IsNullOrWhiteSpace(request.GroupName)) + { + symptomConfig.GroupName = request.GroupName; + } - if (!string.IsNullOrWhiteSpace(request.AttributeKey)) - { - symptomConfig.AttributeKey = request.AttributeKey; - } + if (!string.IsNullOrWhiteSpace(request.AttributeKey)) + { + symptomConfig.AttributeKey = request.AttributeKey; + } - if (!string.IsNullOrWhiteSpace(request.AttributeLabel)) - { - symptomConfig.AttributeLabel = request.AttributeLabel; - } + if (!string.IsNullOrWhiteSpace(request.AttributeLabel)) + { + symptomConfig.AttributeLabel = request.AttributeLabel; + } - if (request.DisplayOrder.HasValue) - { - symptomConfig.DisplayOrder = request.DisplayOrder.Value; - } + if (request.DisplayOrder.HasValue) + { + symptomConfig.DisplayOrder = request.DisplayOrder.Value; + } - if (!string.IsNullOrWhiteSpace(request.Name)) - { - symptomConfig.Name = request.Name; - } + if (!string.IsNullOrWhiteSpace(request.Name)) + { + symptomConfig.Name = request.Name; + } - if (request.Description != null) - { - symptomConfig.Description = request.Description; - } + if (request.Description != null) + { + symptomConfig.Description = request.Description; + } - if (request.IsCritical.HasValue) - { - symptomConfig.IsCritical = request.IsCritical.Value; - } + if (request.IsCritical.HasValue) + { + symptomConfig.IsCritical = request.IsCritical.Value; + } - if (request.AlertMessage != null) - { - symptomConfig.AlertMessage = request.AlertMessage; - } + if (request.AlertMessage != null) + { + symptomConfig.AlertMessage = request.AlertMessage; + } - if (request.IsActive.HasValue) - { - symptomConfig.IsActive = request.IsActive.Value; - } + if (request.IsActive.HasValue) + { + symptomConfig.IsActive = request.IsActive.Value; + } - if (request.Category.HasValue) - { - symptomConfig.Category = request.Category.Value; - } + if (request.Category.HasValue) + { + symptomConfig.Category = request.Category.Value; + } - if (request.TimeScoreList != null) - { - symptomConfig.TimeScoresJson = request.TimeScoreList.Any() - ? JsonSerializer.Serialize(request.TimeScoreList) - : null; - } + if (request.TimeScoreList != null) + { + symptomConfig.TimeScoresJson = request.TimeScoreList.Any() + ? JsonSerializer.Serialize(request.TimeScoreList) + : null; + } - if (request.VenomTypeId != null) - { - symptomConfig.VenomTypeId = request.VenomTypeId; - } + if (request.VenomTypeId != null) + { + symptomConfig.VenomTypeId = request.VenomTypeId; + } - symptomConfig.UpdatedAt = DateTime.UtcNow; + symptomConfig.UpdatedAt = DateTime.UtcNow; - _unitOfWork.GetRepository().Update(symptomConfig); - await _unitOfWork.CommitAsync(); + _unitOfWork.GetRepository().Update(symptomConfig); + await _unitOfWork.CommitAsync(); - // Reload to get updated VenomType - if (symptomConfig.VenomTypeId.HasValue) - { - symptomConfig = await _unitOfWork.GetRepository() - .FirstOrDefaultAsync( - predicate: sc => sc.Id == id, - include: q => q.Include(sc => sc.VenomType), - asNoTracking: false - ); - } + // Reload to get updated VenomType + if (symptomConfig.VenomTypeId.HasValue) + { + symptomConfig = await _unitOfWork.GetRepository() + .FirstOrDefaultAsync( + predicate: sc => sc.Id == id, + include: q => q.Include(sc => sc.VenomType), + asNoTracking: false + ); + } - var response = symptomConfig.Adapt(); - return ApiResponseBuilder.BuildSuccessResponse(response, "Symptom configuration updated successfully!"); - }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error updating symptom configuration with ID {Id}", id); - return ApiResponseBuilder.CreateResponse( - default, false, ex.Message, HttpStatusCode.BadRequest); - } + return symptomConfig.Adapt(); + }); } - public async Task> DeleteSymptomConfigAsync(int id) + public async Task DeleteSymptomConfigAsync(int id) { - try + await _unitOfWork.ExecuteInTransactionAsync(async () => { - return await _unitOfWork.ExecuteInTransactionAsync(async () => - { - var symptomConfig = await _unitOfWork.GetRepository() - .GetByIdAsync(id); + var symptomConfig = await _unitOfWork.GetRepository() + .GetByIdAsync(id); - if (symptomConfig == null) - { - throw new NotFoundException($"Symptom configuration with ID {id} not found."); - } + if (symptomConfig == null) + { + throw new NotFoundException($"Symptom configuration with ID {id} not found."); + } - _unitOfWork.GetRepository().Delete(symptomConfig); - await _unitOfWork.CommitAsync(); + _unitOfWork.GetRepository().Delete(symptomConfig); + await _unitOfWork.CommitAsync(); - return ApiResponseBuilder.BuildSuccessResponse(true, "Symptom configuration deleted successfully!"); - }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error deleting symptom configuration with ID {Id}", id); - return ApiResponseBuilder.CreateResponse( - false, false, ex.Message, HttpStatusCode.BadRequest); - } + return true; // Just to satisfy the transaction return type + }); } - public async Task>>> GetSymptomConfigsGroupedByKeyAsync() + public async Task>> GetSymptomConfigsGroupedByKeyAsync() { - try - { - var symptomConfigs = await _unitOfWork.GetRepository() - .GetListAsync( - predicate: sc => sc.IsActive, - orderBy: o => o.OrderBy(sc => sc.DisplayOrder).ThenBy(sc => sc.Name), - include: q => q.Include(sc => sc.VenomType) - ); - - var grouped = symptomConfigs - .GroupBy(sc => sc.AttributeKey) - .ToDictionary( - g => g.Key, - g => g.ToList().Adapt>() - ); - - return ApiResponseBuilder.BuildSuccessResponse(grouped); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting symptom configurations grouped by key"); - return ApiResponseBuilder.CreateResponse>>( - default, false, ex.Message, HttpStatusCode.BadRequest); - } + var symptomConfigs = await _unitOfWork.GetRepository() + .GetListAsync( + predicate: sc => sc.IsActive, + orderBy: o => o.OrderBy(sc => sc.DisplayOrder).ThenBy(sc => sc.Name), + include: q => q.Include(sc => sc.VenomType) + ); + + return symptomConfigs + .GroupBy(sc => sc.AttributeKey) + .ToDictionary( + g => g.Key, + g => g.ToList().Adapt>() + ); } - public async Task>> GetAllSymptomConfigAsync() + public async Task> GetAllSymptomConfigAsync() { - try - { - var symptomConfigs = await _unitOfWork.GetRepository() - .GetListAsync( - orderBy: o => o.OrderBy(sc => sc.DisplayOrder).ThenBy(sc => sc.Name), - include: q => q.Include(sc => sc.VenomType) - ); + var symptomConfigs = await _unitOfWork.GetRepository() + .GetListAsync( + orderBy: o => o.OrderBy(sc => sc.DisplayOrder).ThenBy(sc => sc.Name), + include: q => q.Include(sc => sc.VenomType) + ); - var response = symptomConfigs.Adapt>(); - return ApiResponseBuilder.BuildSuccessResponse(response); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting all symptom configurations"); - return ApiResponseBuilder.CreateResponse>( - default, false, ex.Message, HttpStatusCode.BadRequest); - } + return symptomConfigs.Adapt>(); } - } } - diff --git a/SnakeAid.Service/Interfaces/IAuthService.cs b/SnakeAid.Service/Interfaces/IAuthService.cs index 08101abc..d027cc23 100644 --- a/SnakeAid.Service/Interfaces/IAuthService.cs +++ b/SnakeAid.Service/Interfaces/IAuthService.cs @@ -1,5 +1,4 @@ using SnakeAid.Core.Enums; -using SnakeAid.Core.Meta; using SnakeAid.Core.Requests.Auth; using SnakeAid.Core.Responses.Auth; @@ -10,30 +9,30 @@ public interface IAuthService /// /// Register a new user account /// - Task> RegisterAsync(RegisterRequest request, RegisterRole? targetRole); + Task RegisterAsync(RegisterRequest request, RegisterRole? targetRole); /// /// Login with email and password /// - Task> LoginAsync(LoginRequest request); + Task LoginAsync(LoginRequest request); /// /// Refresh access token using refresh token /// - Task> RefreshTokenAsync(RefreshTokenRequest request); + Task RefreshTokenAsync(RefreshTokenRequest request); /// /// Login or register with Google ID token /// - Task> GoogleLoginAsync(GoogleLoginRequest request); + Task GoogleLoginAsync(GoogleLoginRequest request); /// /// Logout - invalidate refresh token /// - Task> LogoutAsync(Guid userId); + Task LogoutAsync(Guid userId); /// /// Verify account with OTP and activate user /// - Task> VerifyAccountAsync(VerifyAccountRequest request); + Task VerifyAccountAsync(VerifyAccountRequest request); } diff --git a/SnakeAid.Service/Interfaces/IFirstAidGuidelineService.cs b/SnakeAid.Service/Interfaces/IFirstAidGuidelineService.cs index ccaab362..a6022c3a 100644 --- a/SnakeAid.Service/Interfaces/IFirstAidGuidelineService.cs +++ b/SnakeAid.Service/Interfaces/IFirstAidGuidelineService.cs @@ -9,36 +9,36 @@ public interface IFirstAidGuidelineService /// /// Create a new first aid guideline /// - Task> CreateFirstAidGuidelineAsync(CreateFirstAidGuidelineRequest request); + Task CreateFirstAidGuidelineAsync(CreateFirstAidGuidelineRequest request); /// /// Get first aid guideline by ID /// - Task> GetFirstAidGuidelineByIdAsync(int id); + Task GetFirstAidGuidelineByIdAsync(int id); /// /// Get list of first aid guidelines with pagination and filters /// - Task>> FilterFirstAidGuidelinesAsync(GetFirstAidGuidelineRequest request); + Task> FilterFirstAidGuidelinesAsync(GetFirstAidGuidelineRequest request); /// /// Update an existing first aid guideline /// - Task> UpdateFirstAidGuidelineAsync(int id, UpdateFirstAidGuidelineRequest request); + Task UpdateFirstAidGuidelineAsync(int id, UpdateFirstAidGuidelineRequest request); /// /// Delete a first aid guideline /// - Task> DeleteFirstAidGuidelineAsync(int id); + Task DeleteFirstAidGuidelineAsync(int id); /// /// Get all first aid guidelines without pagination /// - Task>> GetAllFirstAidGuidelineAsync(); + Task> GetAllFirstAidGuidelineAsync(); /// /// Get first aid guidelines by snake species ID /// - Task>> GetFirstAidGuidelinesBySnakeSpeciesIdAsync(int snakeSpeciesId); + Task> GetFirstAidGuidelinesBySnakeSpeciesIdAsync(int snakeSpeciesId); } } diff --git a/SnakeAid.Service/Interfaces/IMediaService.cs b/SnakeAid.Service/Interfaces/IMediaService.cs index 9cade575..6f62cb88 100644 --- a/SnakeAid.Service/Interfaces/IMediaService.cs +++ b/SnakeAid.Service/Interfaces/IMediaService.cs @@ -1,6 +1,5 @@ using System.Security.Claims; using SnakeAid.Core.Domains; -using SnakeAid.Core.Meta; using SnakeAid.Core.Requests.Media; using SnakeAid.Core.Responses.Media; @@ -20,7 +19,7 @@ public interface IMediaService /// Current user claims principal /// Cancellation token /// Response with media details - Task> UploadReportMediaAsync( + Task UploadReportMediaAsync( UploadReportMediaRequest request, MediaReferenceType referenceType, MediaPurpose purpose, diff --git a/SnakeAid.Service/Interfaces/ISnakeAIService.cs b/SnakeAid.Service/Interfaces/ISnakeAIService.cs index fb9a3da7..38abbf06 100644 --- a/SnakeAid.Service/Interfaces/ISnakeAIService.cs +++ b/SnakeAid.Service/Interfaces/ISnakeAIService.cs @@ -1,4 +1,3 @@ -using SnakeAid.Core.Meta; using SnakeAid.Core.Responses.SnakeDetection; namespace SnakeAid.Service.Interfaces; @@ -13,16 +12,16 @@ public interface ISnakeAIService /// /// ID of the ReportMedia entity /// Cancellation token - /// API response with detection results including species info - Task> DetectFromReportMediaAsync(Guid reportMediaId, CancellationToken ct = default); + /// Detection results including species info + Task DetectFromReportMediaAsync(Guid reportMediaId, CancellationToken ct = default); /// /// Get saved recognition result by ID /// /// ID of the SnakeAIRecognitionResult /// Cancellation token - /// API response with recognition result details - Task> GetRecognitionResultAsync(Guid recognitionResultId, CancellationToken ct = default); + /// Recognition result details + Task GetRecognitionResultAsync(Guid recognitionResultId, CancellationToken ct = default); /// /// Detect snake from ReportMedia, map to species, and persist result. @@ -30,8 +29,8 @@ public interface ISnakeAIService /// Public URL of the image (Cloudinary) /// ID of the ReportMedia entity /// Cancellation token - /// API response with detection results including species info - Task> DetectAsync(string imageUrl, Guid reportMediaId, CancellationToken ct = default); + /// Detection results including species info + Task DetectAsync(string imageUrl, Guid reportMediaId, CancellationToken ct = default); /// /// Check if SnakeAI service is healthy (internal use) diff --git a/SnakeAid.Service/Interfaces/ISnakebiteIncidentService.cs b/SnakeAid.Service/Interfaces/ISnakebiteIncidentService.cs index 3837df8e..97a53c48 100644 --- a/SnakeAid.Service/Interfaces/ISnakebiteIncidentService.cs +++ b/SnakeAid.Service/Interfaces/ISnakebiteIncidentService.cs @@ -1,25 +1,20 @@ -using SnakeAid.Core.Meta; -using SnakeAid.Core.Requests; +using SnakeAid.Core.Requests; using SnakeAid.Core.Requests.RescueRequestSession; using SnakeAid.Core.Requests.SnakebiteIncident; -using SnakeAid.Core.Responses.Auth; using SnakeAid.Core.Responses.SnakebiteIncident; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; namespace SnakeAid.Service.Interfaces { public interface ISnakebiteIncidentService { - Task> CreateIncidentAsync(CreateIncidentRequest request, Guid userId); + Task CreateIncidentAsync(CreateIncidentRequest request, Guid userId); - Task> RaiseSessionRangeAsync(RaiseSessionRangeRequest request); + Task GetDetailIncidentAsync(Guid incidentId); - Task> UpdateSymptomReportAsync(Guid incidentId, UpdateSymptomReportRequest request); + Task RaiseSessionRangeAsync(RaiseSessionRangeRequest request); - Task> CancelIncidentAsync(Guid incidentId); + Task UpdateSymptomReportAsync(Guid incidentId, UpdateSymptomReportRequest request); + + Task CancelIncidentAsync(Guid incidentId); } } diff --git a/SnakeAid.Service/Interfaces/ISymptomConfigService.cs b/SnakeAid.Service/Interfaces/ISymptomConfigService.cs index 9a9ed34f..5a3e1ce8 100644 --- a/SnakeAid.Service/Interfaces/ISymptomConfigService.cs +++ b/SnakeAid.Service/Interfaces/ISymptomConfigService.cs @@ -9,36 +9,36 @@ public interface ISymptomConfigService /// /// Create a new symptom configuration /// - Task> CreateSymptomConfigAsync(CreateSymptomConfigRequest request); + Task CreateSymptomConfigAsync(CreateSymptomConfigRequest request); /// /// Get symptom configuration by ID /// - Task> GetSymptomConfigByIdAsync(int id); + Task GetSymptomConfigByIdAsync(int id); /// /// Get list of symptom configurations with pagination and filters /// - Task>> FilterSymptomConfigsAsync(GetSymptomConfigRequest request); + Task> FilterSymptomConfigsAsync(GetSymptomConfigRequest request); /// /// Update an existing symptom configuration /// - Task> UpdateSymptomConfigAsync(int id, UpdateSymptomConfigRequest request); + Task UpdateSymptomConfigAsync(int id, UpdateSymptomConfigRequest request); /// /// Delete a symptom configuration /// - Task> DeleteSymptomConfigAsync(int id); + Task DeleteSymptomConfigAsync(int id); /// /// Get symptom configurations grouped by AttributeKey /// - Task>>> GetSymptomConfigsGroupedByKeyAsync(); + Task>> GetSymptomConfigsGroupedByKeyAsync(); /// /// Get all symptom configurations without pagination /// - Task>> GetAllSymptomConfigAsync(); + Task> GetAllSymptomConfigAsync(); } } diff --git a/docs/01-flows/API_Plan.md b/docs/01-flows/API_Plan.md deleted file mode 100644 index 99098a62..00000000 --- a/docs/01-flows/API_Plan.md +++ /dev/null @@ -1,231 +0,0 @@ -# SnakeAid API Implementation Plan - -This document outlines the proposed API endpoints for the SnakeAid platform, derived from the functional specifications and database design. - -## 1. Authentication -*Manage user identity and sessions.* - -### User (Common) -*Endpoints available to all authenticated users or guests.* - -| | | | | | -| :--- | :--- | :--- | :--- | :--- | -| **POST** | `/api/auth/register` | Register a new account. | ✅ Implemented | feature/SA001-ASPNET_Identity | -| **POST** | `/api/auth/login` | Login and receive JWT. | ✅ Implemented | feature/SA001-ASPNET_Identity | -| **POST** | `/api/auth/refresh` | Refresh access token. | ✅ Implemented | feature/SA001-ASPNET_Identity | -| **POST** | `/api/auth/google` | Login/Register with Google. | ✅ Implemented | feature/SA001-ASPNET_Identity | -| **POST** | `/api/auth/logout` | Logout. | ✅ Implemented | feature/SA001-ASPNET_Identity | -| **POST** | `/api/auth/verify-account` | Verify account using OTP. | ✅ Implemented | feature/SA001-ASPNET_Identity | -| **GET** | `/api/auth/me` | Get current user info. | ✅ Implemented | feature/SA001-ASPNET_Identity | -| **POST** | `/api/email/send-otp` | Send OTP to email. | ✅ Implemented | feature/SA002-ASPI_Email_Intergration | -| **POST** | `/api/otp/check` | Check OTP validity. | ✅ Implemented | feature/SA001-ASPNET_Identity | -| **POST** | `/api/otp/validate` | Validate and consume OTP. | ✅ Implemented | feature/SA001-ASPNET_Identity | - -#### Password Management (Planned) - -| | | | | | -| :--- | :--- | :--- | :--- | :--- | -| **POST** | `/api/auth/forgot-password` | Initiate password reset. | 📝 Planned | - | -| **POST** | `/api/auth/reset-password` | Complete password reset. | 📝 Planned | - | - ---- - -## 2. Account Profiles -*Manage separate profiles for Members, Rescuers, and Experts.* - -### Member - -| | | | | | -| :--- | :--- | :--- | :--- | :--- | -| **GET** | `/api/members/me` | Get current user's member profile. | 📝 Planned | - | -| **PUT** | `/api/members/me` | Update member profile (emergency contact, etc.). | 📝 Planned | - | - -### Rescuer - -| | | | | | -| :--- | :--- | :--- | :--- | :--- | -| **GET** | `/api/rescuers/me` | Get current user's rescuer profile. | 📝 Planned | - | -| **PUT** | `/api/rescuers/me` | Update rescuer profile (bio, availability, type). | 📝 Planned | - | -| **PUT** | `/api/rescuers/me/location` | Update real-time location. | 📝 Planned | - | - -### Expert - -| | | | | | -| :--- | :--- | :--- | :--- | :--- | -| **GET** | `/api/experts/me` | Get current user's expert profile. | 📝 Planned | - | -| **PUT** | `/api/experts/me` | Update expert profile (bio, specialization). | 📝 Planned | - | -| **GET** | `/api/experts/me/certificates` | List my certificates. | 📝 Planned | - | -| **POST** | `/api/experts/me/certificates` | Upload/Submit a new certificate. | 📝 Planned | - | - -#### Public Info - -| | | | | | -| :--- | :--- | :--- | :--- | :--- | -| **GET** | `/api/experts/{expertId}/certificates` | View approved certificates of an expert. | 📝 Planned | - | - ---- - -## 3. Emergency (SOS) -*Critical snakebite incidents and rescue coordination.* - -### Incidents (Patient) - -| | | | | | -| :--- | :--- | :--- | :--- | :--- | -| **POST** | `/api/incidents/sos` | Create a snakebite emergency (Location, Symptoms, Images). | 📝 Planned | - | -| **GET** | `/api/incidents/{id}` | Get incident details and status. | 📝 Planned | - | -| **PUT** | `/api/incidents/{id}/symptoms` | Update symptoms & images (Recalculate severity). | 📝 Planned | - | -| **PUT** | `/api/incidents/{id}/cancel` | Cancel the SOS. | 📝 Planned | - | - -### Rescue Requests (Rescuer) - -| | | | | | -| :--- | :--- | :--- | :--- | :--- | -| **GET** | `/api/incidents/nearby` | List active SOS requests nearby (Rescuer). | 📝 Planned | - | -| **POST** | `/api/incidents/{id}/accept` | Accept an SOS request (Creates a `RescueMission`). | 📝 Planned | - | - -### Missions (Active Rescue) - -| | | | | | -| :--- | :--- | :--- | :--- | :--- | -| **GET** | `/api/missions/active` | Get current active mission (for both Patient and Rescuer). | 📝 Planned | - | -| **PUT** | `/api/missions/{id}/status` | Update status (EnRoute, Arrived, Hospital, Completed). | 📝 Planned | - | -| **POST** | `/api/missions/{id}/location` | Stream location updates during mission (Rescuer). | 📝 Planned | - | -| **GET** | `/api/missions/{id}/tracking` | Get stream of rescuer location (Patient). | 📝 Planned | - | - ---- - -## 4. Snake Catching Service (Removal) -*Non-emergency snake removal service.* - -### Requests - -| | | | | | -| :--- | :--- | :--- | :--- | :--- | -| **POST** | `/api/catching/requests` | Request snake removal (Location, Declared Species/Size). | 📝 Planned | - | -| **GET** | `/api/catching/requests/me` | List my requests. | 📝 Planned | - | -| **GET** | `/api/catching/requests/{id}` | Get request details. | 📝 Planned | - | -| **PUT** | `/api/catching/requests/{id}` | Update request details. | 📝 Planned | - | -| **PUT** | `/api/catching/requests/{id}/confirm` | Confirm and submit request. | 📝 Planned | - | -| **PUT** | `/api/catching/requests/{id}/cancel` | Cancel a request. | 📝 Planned | - | -| **GET** | `/api/catching/requests/nearby` | Find removal jobs (Rescuer). | 📝 Planned | - | - -### Missions - -| | | | | | -| :--- | :--- | :--- | :--- | :--- | -| **POST** | `/api/catching/requests/{id}/accept` | Accept removal job. | 📝 Planned | - | -| **GET** | `/api/catching/missions/{id}` | Get mission details. | 📝 Planned | - | -| **GET** | `/api/catching/missions/{id}/tracking` | Stream rescuer location (Member). | 📝 Planned | - | -| **PUT** | `/api/catching/missions/{id}/status` | Update mission status (EnRoute, Arrived, Completed). | 📝 Planned | - | -| **PUT** | `/api/catching/missions/{id}/report` | Report actual species/size found (Rescuer). | 📝 Planned | - | -| **PUT** | `/api/catching/missions/{id}/complete` | Mark job as completed. | 📝 Planned | - | -| **POST** | `/api/catching/missions/{id}/review` | Rate and review the service (Member). | 📝 Planned | - | - ---- - -## 5. Snake Library & Knowledge Base -*Public access to snake data, identification, and first aid.* - -### Snake Species - -| | | | | | -| :--- | :--- | :--- | :--- | :--- | -| **GET** | `/api/snakes` | List/Search snakes (filters: venomous, family, location). | 📝 Planned | - | -| **GET** | `/api/snakes/{slug}` | Get details of a specific snake (info, images, venom). | 📝 Planned | - | -| **GET** | `/api/snakes/{slug}/distribution` | Get distribution map/polygon data. | 📝 Planned | - | -| **POST** | `/api/snakes/identify` | Identify snake via questionnaire (Q&A flow). | 📝 Planned | - | - -### First Aid - -| | | | | | -| :--- | :--- | :--- | :--- | :--- | -| **GET** | `/api/first-aid/general` | Get general first aid guidelines. | 📝 Planned | - | -| **GET** | `/api/first-aid/species/{slug}` | Get specific guidelines for a snake species. | 📝 Planned | - | - -### Venoms & Antivenoms - -| | | | | | -| :--- | :--- | :--- | :--- | :--- | -| **GET** | `/api/venoms` | List venom types. | 📝 Planned | - | -| **GET** | `/api/antivenoms` | List antivenom types and details. | 📝 Planned | - | -| **GET** | `/api/antivenoms/nearest` | Find facilities with antivenom near a location (lat, long). | 📝 Planned | - | - -### Admin (Content Management) - -| | | | | | -| :--- | :--- | :--- | :--- | :--- | -| **POST** | `/api/admin/snakes` | Create species. | 📝 Planned | - | -| **PUT** | `/api/admin/snakes/{id}` | Update species. | 📝 Planned | - | -| **POST** | `/api/admin/antivenoms` | Manage antivenom data. | 📝 Planned | - | - ---- - -## 6. AI Identification -*AI-powered recognition and expert verification.* - -### Prediction - -| | | | | | -| :--- | :--- | :--- | :--- | :--- | -| **POST** | `/api/aivision/detect` | Upload image for identification (Wraps SnakeAI). | ✅ Implemented | feature/SA005-SnakeAI_Intergration | -| **GET** | `/api/aivision/{id}` | Get result of a specific identification session. | 📝 Planned | feature/SA005-SnakeAI_Intergration | - -### Expert Verification - -| | | | | | -| :--- | :--- | :--- | :--- | :--- | -| **POST** | `/api/aivision/{id}/request-verification` | Request expert review for an AI result. | 📝 Planned | - | -| **POST** | `/api/aivision/{id}/verify` | Confirm or correct the species (Expert Only). | 📝 Planned | - | - ---- - -## 7. Expert Consultation -*Booking and conducting consultations.* - -### Booking - -| | | | | | -| :--- | :--- | :--- | :--- | :--- | -| **GET** | `/api/experts` | Find experts (filter by specialization, rating). | 📝 Planned | - | -| **GET** | `/api/experts/{id}/availability` | Get available time slots. | 📝 Planned | - | -| **POST** | `/api/consultations/book` | Book a specific slot. | 📝 Planned | - | - -### Sessions - -| | | | | | -| :--- | :--- | :--- | :--- | :--- | -| **GET** | `/api/consultations/active` | Get ongoing/upcoming session. | 📝 Planned | - | -| **GET** | `/api/consultations/{id}/join` | Get WebRTC room details/token. | 📝 Planned | - | -| **PUT** | `/api/consultations/{id}/cancel` | Cancel booking. | 📝 Planned | - | - ---- - -## 8. Community & Map - -| | | | | | -| :--- | :--- | :--- | :--- | :--- | -| **POST** | `/api/community/reports` | Report a snake sighting (Public). | 📝 Planned | - | -| **GET** | `/api/community/reports` | Get nearby sightings (Heatmap data). | 📝 Planned | - | -| **GET** | `/api/facilities` | List medical facilities/hospitals. | 📝 Planned | - | - ---- - -## 9. Wallet & Payments - -| | | | | | -| :--- | :--- | :--- | :--- | :--- | -| **GET** | `/api/wallet/balance` | Get current balance. | 📝 Planned | - | -| **GET** | `/api/wallet/transactions` | History. | 📝 Planned | - | -| **POST** | `/api/wallet/deposit` | Initiate deposit (Momo/ZaloPay/Bank). | 📝 Planned | - | -| **POST** | `/api/wallet/withdraw` | Request withdrawal (Experts/Rescuers). | 📝 Planned | - | - ---- - -## 10. Media & Uploads -*File and image management.* - -| | | | | | -| :--- | :--- | :--- | :--- | :--- | -| **POST** | `/api/media/upload-image` | Upload an image. | ✅ Implemented | feature/SA004-Cloudinary_Intergration | -| **POST** | `/api/media/upload-file` | Upload a file. | ✅ Implemented | feature/SA004-Cloudinary_Intergration | diff --git a/docs/01-flows/P1-emergency/S1-identified/_flow.md b/docs/01-flows/P1-emergency/S1-identified/_flow.md deleted file mode 100644 index d5fc52f8..00000000 --- a/docs/01-flows/P1-emergency/S1-identified/_flow.md +++ /dev/null @@ -1,118 +0,0 @@ - -# Mapping Flow P1-S1: Cứu hộ - Nhận diện được rắn (Emergency Rescue) - -> **Flow ID:** P1 | **SubFlow:** S1 - Nhận diện được rắn | **Role:** Member - -Dưới đây là các bước trong Flow P1-S1, mỗi bước tương ứng với một endpoint và quy trình backend: - -## 1. Trang chủ (Home - SOS) -**Screen:** Trang chủ -**Action:** Member bấm SOS -**Backend Process:** -- Tạo `SnakebiteIncident` với Location -- Tạo `RescuerRequest` để tìm Rescuer gần nhất -**Endpoint:** `POST /api/incidents/sos` -**Note:** Tạo incident với GPS location, bắt đầu flow cấp cứu. - -## 2. Cảnh báo khẩn cấp -**Screen:** Cảnh báo khẩn cấp -**Action:** Member thấy Rescuer trên map -**Backend Process:** -- Stream vị trí Rescuer real-time -- Cập nhật `RescueMission.LocationEvent` -**Endpoint:** `GET /api/missions/{id}/tracking` -**Note:** SSE hoặc WebSocket stream vị trí rescuer. - -## 3. Chụp ảnh rắn để AI nhận diện -**Screen:** Chụp ảnh rắn -**Action:** Member chụp ảnh rắn -**Backend Process:** -- Upload ảnh lên Cloudinary -- Gọi YOLO Model qua SnakeAI service -- Lưu kết quả vào `SnakeAIRecognitionResult` -**Endpoints:** -- `POST /api/media/upload-image` (upload ảnh) -- `POST /api/detection/detect` (gọi AI) -**Note:** Wraps SnakeAI `/detect` endpoint. - -## 4. Kết quả nhận diện loài rắn -**Screen:** Kết quả nhận diện -**Action:** System trả kết quả sau khi chụp -**Backend Process:** -- Map YOLO class → `SnakeSpecies` -- Lấy `FirstAidGuidelineOverride` nếu có -**Endpoints:** -- `GET /api/detection/{id}` (lấy kết quả) -- `GET /api/snakes/{slug}` (chi tiết loài) -**Note:** Response nên trả kèm sơ bộ thông tin loài. - -## 5. Hướng dẫn sơ cứu theo loài -**Screen:** Hướng dẫn sơ cứu -**Action:** User bấm xem cách sơ cứu -**Backend Process:** -- Lấy `FirstAidGuideline` theo `Species` → `VenomType` -**Endpoint:** `GET /api/first-aid/species/{slug}` - -## 6. Nhập triệu chứng và chụp vết cắn -**Screen:** Nhập triệu chứng -**Action:** User nhập triệu chứng & chụp vết cắn -**Backend Process:** -- Cập nhật `SymptomsReport` (jsonB) vào Incident -- Tính `SeverityLevel` (AI hoặc rule-based) -**Endpoint:** `PUT /api/incidents/{id}/symptoms` - -## 7. Hướng dẫn chi tiết theo mức độ -**Screen:** Hướng dẫn chi tiết -**Action:** System hiện đánh giá mức độ nghiêm trọng -**Backend Process:** -- Trả về `SeverityLevel` (Nhẹ/Trung bình/Nặng/Nguy kịch) -- Lấy hướng dẫn chi tiết tương ứng -**Endpoint:** `GET /api/incidents/{id}` - -## 8. Theo dõi SOS khẩn cấp -**Screen:** Theo dõi SOS khẩn cấp -**Action:** Member theo dõi trạng thái SOS -**Backend Process:** -- Lấy mission đang active -- Stream trạng thái real-time -**Endpoints:** -- `GET /api/missions/active` (lấy mission hiện tại) -- `GET /api/missions/{id}/tracking` (stream vị trí) - -## 9. Cứu hộ đã đến -**Screen:** Cứu hộ đã đến -**Action:** Rescuer xác nhận đã đến -**Backend Process:** -- Cập nhật `RescueMission.Status` → `Arrived` -**Endpoint:** `PUT /api/missions/{id}/status` -**Note:** Status enum: EnRoute, Arrived, Hospital, Completed. - -## 10. Thanh toán và đánh giá -**Screen:** Thanh toán và đánh giá sau cấp cứu -**Action:** Member thanh toán và đánh giá dịch vụ -**Backend Process:** -- Khởi tạo giao dịch thanh toán -- Gửi rating & review -**Endpoints:** -- `POST /api/wallet/deposit` (thanh toán) -- `POST /api/missions/{id}/review` (đánh giá - cần mở rộng API) - -## 11. Thanh toán thành công -**Screen:** Thanh toán thành công -**Action:** System xác nhận thanh toán -**Backend Process:** -- Cập nhật `RescueMission.Status` → `Completed` -- Phân chia thanh toán (85% Rescuer, 10% Platform, 5% Insurance) -**Endpoints:** -- `PUT /api/missions/{id}/status` -- `GET /api/wallet/transactions` - -## 12. Tìm bệnh viện có huyết thanh -**Screen:** Bản đồ tìm kiếm bệnh viện có huyết thanh -**Action:** Member tìm bệnh viện gần nhất -**Backend Process:** -- Query danh sách cơ sở y tế có antivenom -- Lọc theo vị trí GPS và loài rắn -**Endpoints:** -- `GET /api/antivenoms/nearest` (tìm antivenom gần nhất) -- `GET /api/facilities` (danh sách bệnh viện) \ No newline at end of file diff --git a/docs/01-flows/P1-emergency/S1-identified/aivision-detect/aivision-detect.introduction.md b/docs/01-flows/P1-emergency/S1-identified/aivision-detect/aivision-detect.introduction.md deleted file mode 100644 index eeb70a86..00000000 --- a/docs/01-flows/P1-emergency/S1-identified/aivision-detect/aivision-detect.introduction.md +++ /dev/null @@ -1,37 +0,0 @@ -# Snake Detection - Giới thiệu - -## Tổng quan - -`POST /api/detection/detect` là endpoint cho phép nhận diện loài rắn từ ảnh sử dụng YOLO model. Endpoint này đóng vai trò **wrapper** cho SnakeAI FastAPI service (`/detect/url`). - -## Vị trí trong User Flow - -- **Flow:** P1 - Emergency Rescue -- **Screen:** 3. Chụp ảnh rắn để AI nhận diện -- **Role:** Member - -## Lý do thiết kế tách riêng - -Chọn phương án **tách riêng** upload và AI detection: - -| Endpoint | Mục đích | -|----------|----------| -| `POST /api/media/upload-image` | Upload ảnh lên Cloudinary → trả về `imageUrl` | -| `POST /api/detection/detect` | Nhận `imageUrl` → gọi SnakeAI → trả về kết quả | - -**Lợi ích:** -- ✅ Reusable: upload endpoint dùng cho nhiều mục đích khác -- ✅ Dễ debug và monitor từng bước -- ✅ Client có thể retry từng step -- ✅ Parallel uploads (upload nhiều ảnh cùng lúc) - -## Use Cases - -1. **Emergency SOS (P1-S1):** Member chụp ảnh rắn → AI nhận diện → hiển thị kết quả + first aid -2. **Snake Catching (P2):** Member báo cáo rắn → AI xác định loài → tính phí phù hợp -3. **Library Browse:** User upload ảnh → AI identify → navigates to species page - -## Tham khảo - -- [SnakeAI Model Endpoint API Reference](../../02-layers/ai/SankeAi.introduction.md) -- [Cloudinary Integration](../../02-layers/cloudinary/cloudinary.introduction.md) diff --git a/docs/01-flows/P1-emergency/S1-identified/snake-detection/snake-detection.introduction.md b/docs/01-flows/P1-emergency/S1-identified/snake-detection/snake-detection.introduction.md deleted file mode 100644 index a65a2f29..00000000 --- a/docs/01-flows/P1-emergency/S1-identified/snake-detection/snake-detection.introduction.md +++ /dev/null @@ -1,41 +0,0 @@ -# Snake Detection - Introduction - -## 🎯 Overview - -`POST /api/detection/detect/{reportMediaId}` là endpoint chính cho chức năng nhận diện loài rắn, sử dụng mô hình AI kết hợp với cơ sở dữ liệu loài (SnakeLibs) để cung cấp thông tin chi tiết và hướng dẫn sơ cứu. - -Hệ thống hoạt động theo quy trình **Two-Step Flow**: -1. **Media Upload:** Upload ảnh rắn để tạo `ReportMedia`. -2. **Detection:** Gọi AI để phân tích ảnh đó và map kết quả với `SnakeSpecies`. - -## 🔄 User Flow (Phase 2) - -- **Flow:** P1 - Emergency Rescue & P2 - Education -- **Screen:** Camera/Upload Screen -> Detection Result Screen -- **Actors:** Member, Guest, System Admin - -## 🏗 Architecture - -### 1. Separation of Concerns -Chúng tôi tách biệt việc upload và detection để tối ưu hóa hiệu năng và quản lý dữ liệu: - -| Endpoint | Mục đích | -|----------|----------| -| `POST /api/media/report` | Upload ảnh lên Cloudinary và lưu metadata vào DB (`ReportMedia`) | -| `POST /api/detection/detect/{reportMediaId}` | Trigger AI analysis trên ảnh đã upload | - -### 2. SnakeLibs Integration -Không chỉ trả về kết quả raw từ YOLO (VD: "cobra"), hệ thống còn map kết quả này với entity `SnakeSpecies` trong database để trả về: -- **Common Name & Scientific Name** (Tiếng Việt/Anh) -- **Venom Status & Risk Level** -- **First Aid Guidelines** (Hướng dẫn sơ cứu cụ thể cho từng loại nọc độc) - -## 💡 Key Features (V3) -- **Strict JSON Structure:** Response tuân thủ cấu trúc JSON strict với `ai_metadata` và `results`. -- **Fallback Logic:** Tự động tìm hướng dẫn sơ cứu từ Venom Type nếu không có override cụ thể cho loài. -- **Persistence:** Mọi kết quả nhận diện đều được lưu lại (`SnakeAIRecognitionResult`) để audit và training sau này. - -## 📚 References -- [Implementation Plan](./snake-detection.plan.md) -- [Source Code Status](./snake-detection.sourcecode.md) -- [Usage Guide](./snake-detection.usageguide.md) diff --git a/docs/01-flows/P1-emergency/S1-identified/snake-detection/snake-detection.plan.md b/docs/01-flows/P1-emergency/S1-identified/snake-detection/snake-detection.plan.md deleted file mode 100644 index 0dc3dd8d..00000000 --- a/docs/01-flows/P1-emergency/S1-identified/snake-detection/snake-detection.plan.md +++ /dev/null @@ -1,279 +0,0 @@ -# Snake Detection (Phase 2 & 2.1) - Implementation Plan ✅ COMPLETED - -## 1. Overview -**STATUS: ✅ COMPLETED** - Giai đoạn 2 đã hoàn thành việc tích hợp kết quả nhận diện từ AI (YOLO) với cơ sở dữ liệu loài rắn (SnakeLibs) của hệ thống. -Mục tiêu đã đạt được: Lưu trữ lịch sử nhận diện và trả về thông tin chi tiết của loài rắn (tên khoa học, độ độc, ...) thay vì chỉ trả về nhãn raw từ mô hình AI. - - - -## 2. Endpoint Overview (Two-Step Flow) - -Quy trình nhận diện sẽ tuân thủ nguyên tắc: **Upload Media & Create Entity trước -> Detect sau**. Điều này đảm bảo mọi kết quả nhận diện đều gắn liền với một `ReportMedia` hợp lệ trong hệ thống. - -### Step 1: Upload Media (Tạo Entity) - -Endpoint mới chuyên dụng để upload ảnh cho các báo cáo/nghiệp vụ. - -- **URL:** `POST /api/media/report` -- **Content-Type:** `multipart/form-data` -- **Query Params:** - - `Type` (MediaReferenceType): `CommunityReport`, `SnakebiteIncident`, etc. - - `Purpose` (MediaPurpose): `SnakeIdentification` (Default: `SnakeIdentification`). -- **Form Data (Body):** - - `File`: (Binary) File ảnh/video. - - `ReferenceId` (Guid): ID của Parent Entity (IncidentId, ReportId...) **[REQUIRED]**. -- **Flow:** - 1. Validate file & permission. - 2. Upload Cloudinary -> Get URL. - 3. Lưu bảng `ReportMedia` với `ReferenceId` và `ReferenceType`. - 4. Return `ReportMedia` object (bao gồm `Id` và `MediaUrl`). - -### Step 2: Detect Snake (Nhận diện) - -Endpoint nhận diện giờ đây sẽ làm việc với `ReportMediaId` thay vì URL trần. - -- **URL:** `POST /api/detection/detect` -- **Params:** `ReportMediaId` (Guid) -- **Flow:** - 1. Health Check: Gọi `_snakeAIService.IsHealthyAsync()`. - 2. Validate `ReportMediaId` tồn tại. - 3. Gọi AI Service với URL của Media đó. - 4. Lưu `SnakeAIRecognitionResult` tham chiếu tới `ReportMediaId`. - 5. Return kết quả nhận diện + thông tin loài. - ---- - -## 3. Entity Graph & Data Model - -Hệ thống sử dụng các entity sau để mapping và lưu trữ kết quả: - -```mermaid -classDiagram - class SnakeAIRecognitionResult { - Guid Id - Guid ReportMediaId - int AIModelId - string YoloClassName - decimal Confidence - int? DetectedSpeciesId - bool IsMapped - } - - class ReportMedia { - Guid Id - string MediaUrl - MediaPurpose Purpose - bool RequiresAIProcessing - } - - class AIModel { - int Id - string Version - bool IsActive - bool IsDefault - } - - class AISnakeClassMapping { - Guid Id - int AIModelId - int SnakeSpeciesId - string YoloClassName - int YoloClassId - } - - class SnakeSpecies { - int Id - string ScientificName - string CommonName - bool IsVenomous - } - - %% Relationships - SnakeAIRecognitionResult --> ReportMedia : "Belongs to" - SnakeAIRecognitionResult --> AIModel : "Has AIModelId" - SnakeAIRecognitionResult --> SnakeSpecies : "Mapped to DetectedSpeciesId" - - AIModel "1" --> "*" AISnakeClassMapping : "Has Mappings" - AISnakeClassMapping --> SnakeSpecies : "Maps to" - - %% Logic Mapping - note for AISnakeClassMapping "MAPPING LOGIC:\nAIModelId + YoloClassName -> SnakeSpeciesId" -``` - -### Chi tiết Entities: - -1. **`ReportMedia`**: - * Lưu trữ thông tin file ảnh/video upload (URL từ Cloudinary). - * Được liên kết với các nghiệp vụ khác (Community Report, SOS, Rescue). - * Trường `RequiresAIProcessing` cờ đánh dấu cần gọi AI. - -2. **`SnakeAIRecognitionResult`**: - * Lưu trữ kết quả mỗi lần request (Audit/History). - * FK `ReportMediaId`: Liên kết 1-n (một ảnh có thể chạy AI nhiều lần hoặc nhiều model khác nhau). - * Có trường `DetectedSpeciesId` là kết quả sau khi map. - -3. **`AIModel`**: - * Định danh phiên bản model đang chạy (VD: `snake-yolo12-v1.0`). - * Cần thiết để chọn đúng bộ mapping (vì các version model có thể có bộ class khác nhau). - -4. **`AISnakeClassMapping`**: - * Bảng tra cứu: `(AIModelId, YoloClassName) => SnakeSpeciesId`. - * Cho phép decouple việc huấn luyện AI và quản lý dữ liệu loài trong DB. - -## 3. Updated Architecture Loop - -``` -[Client] -> [API: SnakeDetectionController] - | - v - [SnakeAIService] -> [SnakeAI FastAPI] -> (1) Get YOLO Result - | - v - [Service Logic] - |--> (2) Get Active AIModel (Cacheable) - |--> (3) Mapping: Find AISnakeClassMapping (ModelId + ClassName) - |--> (4) Enrich: Get SnakeSpecies info - |--> (5) Persist: Save SnakeAIRecognitionResult to DB - | - v - [Client Response] (Includes Species Info + Risk Level) -``` - -## 4. Implementation Steps - -### Step 1: Repositories & Database Access -Hiện tại dự án sử dụng `GenericRepository`. Cần inject `IUnitOfWork` hoặc các Repository `IGenericRepository` cần thiết vào `SnakeAIService`: -- `IGenericRepository` -- `IGenericRepository` -- `IGenericRepository` -- `IGenericRepository` - -### Step 2: Update `SnakeAIService` -Cập nhật method `DetectAsync` để thực hiện full flow: -1. **Call AI**: Giữ nguyên logic gọi Refit Client. -2. **Validation**: Kiểm tra kết quả trả về. -3. **Mapping Flow**: - * Lấy `ModelVersion` từ AI Response. - * Tìm `AIModel` trong DB khớp version. - * Tìm `AISnakeClassMapping` khớp `AIModel.Id` và `YoloClassName`. - * Nếu có mapping -> set `DetectedSpeciesId` và `IsMapped = true`. -4. **Persist**: Tạo và lưu `SnakeAIRecognitionResult`. -5. **Enrich Response**: - * Cập nhật `SnakeDetectionResponse` để trả về thêm `SpeciesId`, `ScientificName`, `RiskLevel` cho Frontend hiển thị cảnh báo. - -### Step 3: DTO Updates -Cập nhật `SnakeDetectionResponse.cs` và `SnakeAIDetection.cs` để thêm các trường thông tin loài rắn. - -```csharp -public class SnakeAIDetection -{ - // Existing fields... - public int? SpeciesId { get; set; } - public string? SpeciesName { get; set; } // Tên thường gọi (VN) - Display on UI - public string? ScientificName { get; set; } // Tên khoa học - Display on UI - public bool? IsVenomous { get; set; } // Cờ xác định rắn độc - Trigger Red Banner/Alert - public float? RiskLevel { get; set; } // Mức độ nguy hiểm (0-10) - Display Bar Chart -} -``` - -## 5. Timeline Estimate -- **Repository Setup**: 30 mins -- **Mapping Logic Implementation**: 1.5 hours -- **Response Enrichment**: 30 mins -- **Testing (Integration)**: 1 hour - ---- - ---- - -## Phase 2.1: Response Optimization - -> **Added:** 2026-02-01 -> **Goal:** Optimize response for Mobile App development (reduce DTO mapping, support offline-first logic). - -### 1. Response Structure Redesign (V3) -Chuyển từ Flat DTO sang cấu trúc phân tách Metadata/Results để rõ ràng nguồn dữ liệu. - -```json -{ - // 1. GLOBAL METADATA (Nguồn: FastAPI) - "ai_metadata": { - "model_version": "snake-yolo12-v1.0", - "image_width": 1280, - "image_height": 720, - "detection_count": 1, - "warnings": { - "blur": 0.05, // Cảnh báo ảnh mờ - "brightness": 0.45, // Độ sáng - "too_small": 0.0 // Vật thể quá nhỏ - } - }, - - // 2. DETECTION RESULTS list - "results": [ - { - // 2.1 AI INFO (Nguồn: FastAPI - Specific Detection) - "ai_detection": { - "class_id": 0, - "class_name": "king_cobra", - "confidence": 0.94, - "bbox": { - "x1": 100, "y1": 200, "x2": 300, "y2": 400 - } - }, - - // 2.2 ENTITY INFO (Nguồn: SnakeSpecies.cs - Full Entity) - "snake": { - // --- Scalar Fields --- - "id": 101, - "scientificName": "Ophiophagus hannah", - "commonName": "Rắn hổ mang chúa", - "slug": "ran-ho-mang-chua", - "imageUrl": "https://...", - "description": "Loài rắn độc lớn nhất thế giới...", - "identificationSummary": "Cổ bành rộng, mắt đen, vảy trơn...", - "isVenomous": true, - "riskLevel": 9.5, - "isActive": true, - - // --- JSONB Fields --- - "identification": { - "physicalTraits": ["Cổ bành", "Mắt đen"], - "behaviors": ["Chủ động tấn công khi bị đe dọa"], - "habitat": "Rừng nhiệt đới, khu dân cư ven rừng" - }, - - "symptomsByTime": [ - { - "timeRange": "0-15p", - "signs": ["Đau buốt", "Sưng to"], - "isCritical": false - } - ], - - // --- SPECIAL LOGIC: FIRST AID --- - // Nếu DB null -> Tự động điền từ VenomType (Fallback) - "firstAidGuidelineOverride": { - "mode": 0, // Append/Replace - "steps": [ - "Trấn an nạn nhân", - "Bất động chi bị cắn" - ] - }, - - // --- Navigation Props (Có thể null để tránh loop/heavy payload) --- - "primaryVenomType": 0 // Enum Value - } - } - ] -} -``` - -### 2. First Aid Fallback Logic -Frontend cần hiển thị hướng dẫn sơ cứu ngay lập tức. Logic fallback như sau: - -1. Kiểm tra `SnakeSpecies.FirstAidGuidelineOverride` (JSONB). -2. Nếu `Null` -> Truy vấn ngược qua relation: - `SnakeSpecies -> SpeciesVenoms -> VenomType -> FirstAidGuidelineId` -3. Lấy Content từ `FirstAidGuideline` và populate vào field trả về (đổi tên thành `firstAidGuideline`). -4. **Serialization**: Cấu hình Enum thành String (`Neurotoxic` thay vì `0`). diff --git a/docs/01-flows/P1-emergency/S1-identified/snake-detection/snake-detection.prompt.md b/docs/01-flows/P1-emergency/S1-identified/snake-detection/snake-detection.prompt.md deleted file mode 100644 index b4569da6..00000000 --- a/docs/01-flows/P1-emergency/S1-identified/snake-detection/snake-detection.prompt.md +++ /dev/null @@ -1,406 +0,0 @@ -# Snake Detection - Implementation Prompt ✅ COMPLETED - -> **Loại file:** `prompt.md` - Yeu cầu chi tiết từng bước cho Agent -> **Timeline:** Phase 2 Implementation ✅ COMPLETED -> **Context doc:** `snake-detection-with-snakelibs.plan.md` -> **Status:** Implementation completed successfully with Service Layer Pattern - ---- - -## ✅ COMPLETED OBJECTIVES - -**Successfully implemented** Phase 2 của tính năng Snake Detection, bao gồm: -1. ✅ Endpoint upload media mới: `POST /api/media/report` -2. ✅ Refactor endpoint detect: `POST /api/detection/detect` nhận `ReportMediaId` thay vì URL -3. ✅ Service Layer Pattern implementation với MediaService và SnakeAIService -4. ✅ Persistence logic: Lưu kết quả nhận diện vào DB với species mapping -5. ✅ NullReferenceException fixes và ClaimsPrincipal handling - ---- - -## TASK 1: Create Media Upload Endpoint - -### 1.1 Create Request DTO - -**File:** `SnakeAid.Core/Requests/Media/UploadReportMediaRequest.cs` - -```csharp -using System; -using System.ComponentModel.DataAnnotations; -using Microsoft.AspNetCore.Http; -using SnakeAid.Core.Domains; - -namespace SnakeAid.Core.Requests.Media; - -public class UploadReportMediaRequest -{ - [Required] - public IFormFile File { get; set; } - - [Required] - public Guid ReferenceId { get; set; } -} -``` - -### 1.2 Create Response DTO - -**File:** `SnakeAid.Core/Responses/Media/ReportMediaResponse.cs` - -```csharp -using System; - -namespace SnakeAid.Core.Responses.Media; - -public class ReportMediaResponse -{ - public Guid Id { get; set; } - public string MediaUrl { get; set; } - public string FileName { get; set; } - public string ContentType { get; set; } - public long FileSize { get; set; } -} -``` - -### 1.3 Create Endpoint in MediaController - -**File:** `SnakeAid.Api/Controllers/MediaController.cs` - -Thêm method mới vào controller hiện có: - -```csharp -[HttpPost("report")] -[Consumes("multipart/form-data")] -[ValidateFile(maxSizeInMB: 10, allowedExtensions: new[] { ".jpg", ".jpeg", ".png", ".webp" }, formFieldName: "file")] -public async Task UploadReportMedia( - [FromQuery] MediaReferenceType type, - [FromQuery] MediaPurpose purpose = MediaPurpose.SnakeIdentification, - [FromForm] UploadReportMediaRequest request, - CancellationToken ct) -{ - // 1. Upload to Cloudinary - var uploadResult = await _cloudinaryService.UploadImageAsync(request.File, User, "report-media", ct); - - // 2. Create ReportMedia entity - var reportMedia = new ReportMedia - { - Id = Guid.NewGuid(), - ReferenceId = request.ReferenceId, - ReferenceType = type, - FileName = request.File.FileName, - MediaUrl = uploadResult.Url, - ContentType = request.File.ContentType, - FileSize = request.File.Length, - Purpose = purpose, - RequiresAIProcessing = purpose == MediaPurpose.SnakeIdentification - }; - - // 3. Save to DB - await _unitOfWork.Repository().AddAsync(reportMedia, ct); - await _unitOfWork.CommitAsync(ct); - - // 4. Return response with URL - var response = _mapper.Map(reportMedia); - return Ok(ApiResponseBuilder.BuildSuccessResponse(response, "Media uploaded successfully.")); -} -``` - -**Lưu ý:** -- Inject `IUnitOfWork` vào constructor của `MediaController` -- Thêm using cho `SnakeAid.Core.Domains` - ---- - -## TASK 2: Refactor Snake Detection Endpoint - -### 2.1 Update Request DTO - -**File:** `SnakeAid.Core/Requests/Detection/SnakeDetectionRequest.cs` - -```csharp -using System; -using System.ComponentModel.DataAnnotations; - -namespace SnakeAid.Core.Requests.Detection; - -public class SnakeDetectionRequest -{ - [Required] - public Guid ReportMediaId { get; set; } -} -``` - -### 2.2 Update Response DTO - -**File:** `SnakeAid.Core/Responses/Detection/SnakeAIDetection.cs` - -Thêm các field mới cho Species info: - -```csharp -public class SnakeAIDetection -{ - // Existing YOLO fields - public string ClassName { get; set; } - public float Confidence { get; set; } - public int[] BoundingBox { get; set; } - - // NEW: Species info from mapping - public int? SpeciesId { get; set; } - public string? SpeciesName { get; set; } // CommonName - public string? ScientificName { get; set; } - public bool? IsVenomous { get; set; } - public float? RiskLevel { get; set; } -} -``` - -### 2.3 Update Controller - -**File:** `SnakeAid.Api/Controllers/SnakeDetectionController.cs` - -```csharp -[HttpPost("detect/{reportMediaId:guid}")] -public async Task Detect([FromRoute] Guid reportMediaId, CancellationToken ct = default) -{ - // 1. Health Check (fail-fast) - if (!await _snakeAIService.IsHealthyAsync(ct)) - { - return StatusCode(503, ApiResponseBuilder.BuildErrorResponse("AI Service is currently unavailable.")); - } - - // 2. Call Service directly with ID - var result = await _snakeAIService.DetectFromReportMediaAsync(reportMediaId, ct); - - return StatusCode(result.StatusCode, result); -} -``` - ---- - -## TASK 3: Implement Mapping & Persistence in Service - -### 3.1 Update ISnakeAIService Interface - -**File:** `SnakeAid.Service/Interfaces/ISnakeAIService.cs` - -```csharp -Task DetectAsync(string imageUrl, Guid reportMediaId, CancellationToken ct = default); -``` - -### 3.2 Update SnakeAIService Implementation - -**File:** `SnakeAid.Service/Implements/SnakeAIService.cs` - -```csharp -public async Task DetectAsync(string imageUrl, Guid reportMediaId, CancellationToken ct = default) -{ - // 1. Call AI Service (existing logic) - var aiResponse = await _snakeAIClient.DetectAsync(new { image_url = imageUrl }, ct); - - // 2. Get Active AIModel - var activeModel = await _unitOfWork.Repository() - .FindAsync(m => m.IsActive && m.IsDefault, ct); - - if (activeModel == null) - { - throw new InvalidOperationException("No active AI model found."); - } - - // 3. Process each detection result - var enrichedDetections = new List(); - - foreach (var detection in aiResponse.Detections) - { - var enriched = new SnakeAIDetection - { - ClassName = detection.ClassName, - Confidence = detection.Confidence, - BoundingBox = detection.BoundingBox - }; - - // 4. Map to SnakeSpecies - var mapping = await _unitOfWork.Repository() - .FindAsync(m => m.AIModelId == activeModel.Id && m.YoloClassName == detection.ClassName, ct); - - if (mapping != null) - { - var species = await _unitOfWork.Repository() - .GetByIdAsync(mapping.SnakeSpeciesId, ct); - - if (species != null) - { - enriched.SpeciesId = species.Id; - enriched.SpeciesName = species.CommonName; - enriched.ScientificName = species.ScientificName; - enriched.IsVenomous = species.IsVenomous; - enriched.RiskLevel = species.RiskLevel; - } - } - - // 5. Save Recognition Result - var recognitionResult = new SnakeAIRecognitionResult - { - Id = Guid.NewGuid(), - ReportMediaId = reportMediaId, - AIModelId = activeModel.Id, - YoloClassName = detection.ClassName, - Confidence = (decimal)detection.Confidence, - DetectedSpeciesId = enriched.SpeciesId, - IsMapped = enriched.SpeciesId.HasValue, - Status = RecognitionStatus.Completed - }; - - await _unitOfWork.Repository().AddAsync(recognitionResult, ct); - - enrichedDetections.Add(enriched); - } - - await _unitOfWork.CommitAsync(ct); - - return new SnakeDetectionResponse - { - Detections = enrichedDetections, - ModelVersion = activeModel.Version - }; -} -``` - ---- - -## TASK 4: Dependencies & Registration - -### 4.1 Inject IUnitOfWork into Controllers - -Đảm bảo các controller được inject `IUnitOfWork`: -- `MediaController` -- `SnakeDetectionController` - -### 4.2 Mapster Configuration - -**File:** `SnakeAid.Api/Configurations/MapsterConfig.cs` (hoặc file cấu hình tương tự) - -```csharp -config.NewConfig(); -``` - ---- - -## VERIFICATION STEPS - -1. **Build Solution**: `dotnet build` -2. **Run Tests**: `dotnet test` -3. **Manual Test Flow**: - - Upload image via `POST /api/media/report?type=SnakebiteIncident&purpose=SnakeIdentification` - - Use returned `Id` to call `POST /api/detection/detect` - - Verify response includes Species info - ---- - -## EXPECTED OUTPUT - -- ✅ Endpoint `POST /api/media/report` returns `ReportMediaResponse` with `Id` and `MediaUrl` -- ✅ Endpoint `POST /api/detection/detect` accepts `ReportMediaId` -- ✅ Detection response includes `SpeciesName`, `ScientificName`, `IsVenomous`, `RiskLevel` -- ✅ Data persisted in `ReportMedia` and `SnakeAIRecognitionResult` tables - ---- - -## TASK 5: Response Optimization (Phase 2.1) - -### 5.1 Redesign Response (V3 Strict) - -Implement the response structure exactly matching the JSON below. Do not omit any fields. - -**File:** `SnakeAid.Core/Responses/SnakeDetection/SnakeDetectionResponse.cs` - -```json -{ - // 1. GLOBAL METADATA (Nguồn: FastAPI) - "ai_metadata": { - "model_version": "snake-yolo12-v1.0", - "image_width": 1280, - "image_height": 720, - "detection_count": 1, - "warnings": { - "blur": 0.05, // Cảnh báo ảnh mờ - "brightness": 0.45, // Độ sáng - "too_small": 0.0 // Vật thể quá nhỏ - } - }, - - // 2. DETECTION RESULTS list - "results": [ - { - // 2.1 AI INFO (Nguồn: FastAPI - Specific Detection) - "ai_detection": { - "class_id": 0, - "class_name": "king_cobra", - "confidence": 0.94, - "bbox": { - "x1": 100, "y1": 200, "x2": 300, "y2": 400 - } - }, - - // 2.2 ENTITY INFO (Nguồn: SnakeSpecies.cs - Full Entity) - "snake": { - // --- Scalar Fields --- - "id": 101, - "scientificName": "Ophiophagus hannah", - "commonName": "Rắn hổ mang chúa", - "slug": "ran-ho-mang-chua", - "imageUrl": "https://...", - "description": "Loài rắn độc lớn nhất thế giới...", - "identificationSummary": "Cổ bành rộng, mắt đen, vảy trơn...", - "isVenomous": true, - "riskLevel": 9.5, - "isActive": true, - - // --- JSONB Fields (Lưu ý: Map trực tiếp Object/List, không được stringify thủ công) --- - "identification": { // [JSONB] Maps to 'Identification' - "physicalTraits": ["Cổ bành", "Mắt đen"], - "behaviors": ["Chủ động tấn công khi bị đe dọa"], - "habitat": "Rừng nhiệt đới, khu dân cư ven rừng" - }, - - "symptomsByTime": [ // [JSONB] Maps to 'SymptomsByTime' - { - "timeRange": "0-15p", - "signs": ["Đau buốt", "Sưng to"], - "isCritical": false - } - ], - - // --- SPECIAL LOGIC: FIRST AID --- - // Nếu DB null -> Tự động điền từ VenomType (Fallback) - "firstAidGuidelineOverride": { // [JSONB] Maps to 'FirstAidGuidelineOverride' - "mode": 0, // Append/Replace - "steps": [ - "Trấn an nạn nhân", - "Bất động chi bị cắn" - ] - }, - - // --- Navigation Props (Có thể null để tránh loop/heavy payload) --- - "primaryVenomType": 0 // Enum Value - } - } - ] -} -``` - -### 5.2 Implementation Requirements - -1. **DTO Structure**: - * `SnakeDetectionResponse`: Root object. - * `AiMetadata`: Maps to `ai_metadata`. - * `DetectionResult`: Item in `results`. - * `AiDetection`: Maps to `ai_detection`. - * `SnakeSpecies`: Maps to `snake` (Reuse existing Entity or DTO, but ensure all fields are present). - -2. **First Aid Logic**: - * Field name must be `firstAidGuidelineOverride`. - * **Logic**: If `SnakeSpecies.FirstAidGuidelineOverride` is null, look up `SpeciesVenoms -> VenomType -> FirstAidGuideline`. - * If found, create a temporary `FirstAidOverride` object with `Mode = 0` (Append) and populate `Steps`. - -3. **Serialization**: - * Ensure Enums serialize as Integers (or Strings if requested, but JSON shows `0` for Mode). *Correction from previous plan: JSON above shows `0` for Mode and `0` for primaryVenomType. Use default int serialization unless specified otherwise.* - * Use `[JsonPropertyName("...")]` to match snake_case keys exactly. - diff --git a/docs/01-flows/P1-emergency/S1-identified/snake-detection/snake-detection.sourcecode.md b/docs/01-flows/P1-emergency/S1-identified/snake-detection/snake-detection.sourcecode.md deleted file mode 100644 index 0412909d..00000000 --- a/docs/01-flows/P1-emergency/S1-identified/snake-detection/snake-detection.sourcecode.md +++ /dev/null @@ -1,453 +0,0 @@ -# Snake Detection - Usage Guide Status - -> **Loại file:** `sourcecode.md` - Thể hiện trạng thái codebase sau khi implement -> **Timeline:** Phase 2 Implementation ✅ COMPLETED -> **Context doc:** `snake-detection.plan.md` - ---- - -## ✅ IMPLEMENTATION SUMMARY - -Phase 2 Snake Detection đã được implement thành công với: -- **Service Layer Pattern**: Controller → Service → Repository/UOW -- **Two-Step Flow**: Upload Media → Detect with Species Mapping -- **Database Persistence**: SnakeAIRecognitionResult với species mapping -- **Error Handling**: NullReferenceException fixes và proper user context - ---- - -## 📁 CODE STRUCTURE - -### **1. API Controllers** - -#### **MediaController.cs** -```csharp -[Route("api/media")] -public class MediaController : BaseController -{ - private readonly IMediaService _mediaService; - - [HttpPost("report")] - [ValidateFile(maxSizeInMB: 10, allowedExtensions: new[] { ".jpg", ".jpeg", ".png", ".webp" })] - public async Task UploadReportMedia( - [FromQuery] MediaReferenceType type, - [FromForm] UploadReportMediaRequest request, - [FromQuery] MediaPurpose purpose = MediaPurpose.SnakeIdentification, - CancellationToken ct = default) - { - var result = await _mediaService.UploadReportMediaAsync(request, type, purpose, User, ct); - return StatusCode(result.StatusCode, result); - } -} -``` - -#### **SnakeDetectionController.cs** -```csharp -[Route("api/detection")] -public class SnakeDetectionController : BaseController -{ - private readonly ISnakeAIService _snakeAIService; - - [HttpPost("detect/{reportMediaId:guid}")] - public async Task Detect([FromRoute] Guid reportMediaId, CancellationToken ct = default) - { - var result = await _snakeAIService.DetectFromReportMediaAsync(reportMediaId, ct); - return StatusCode(result.StatusCode, result); - } - - [HttpGet("{id:guid}")] - public async Task GetDetectionResult(Guid id, CancellationToken ct = default) - { - var result = await _snakeAIService.GetRecognitionResultAsync(id, ct); - return StatusCode(result.StatusCode, result); - } -} -``` - ---- - -### **2. Service Layer** - -#### **IMediaService.cs** -```csharp -public interface IMediaService -{ - Task> UploadReportMediaAsync( - UploadReportMediaRequest request, - MediaReferenceType referenceType, - MediaPurpose purpose, - ClaimsPrincipal user, - CancellationToken ct = default); -} -``` - -#### **MediaService.cs** -```csharp -public class MediaService : IMediaService -{ - private readonly IUnitOfWork _unitOfWork; - private readonly ICloudinaryService _cloudinaryService; - private readonly ILogger _logger; - - public async Task> UploadReportMediaAsync( - UploadReportMediaRequest request, - MediaReferenceType referenceType, - MediaPurpose purpose, - ClaimsPrincipal user, - CancellationToken ct = default) - { - // 1. Upload file to Cloudinary with user context - var uploadResult = await _cloudinaryService.UploadImageAsync(request.File, user, "report-media", ct); - - // 2. Save media record to database with transaction - return await _unitOfWork.ExecuteInTransactionAsync(async () => - { - var reportMedia = new ReportMedia - { - Id = Guid.NewGuid(), - FileName = request.File.FileName, - MediaUrl = uploadResult.SecureUrl, - ContentType = request.File.ContentType, - FileSize = request.File.Length, - ReferenceId = request.ReferenceId, - ReferenceType = referenceType, - Purpose = purpose, - RequiresAIProcessing = purpose == MediaPurpose.SnakeIdentification, - IsProcessed = false, - CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow - }; - - await _unitOfWork.GetRepository().InsertAsync(reportMedia); - await _unitOfWork.CommitAsync(); - - var response = new ReportMediaResponse - { - Id = reportMedia.Id, - MediaUrl = reportMedia.MediaUrl, - FileName = reportMedia.FileName, - ContentType = reportMedia.ContentType, - FileSize = reportMedia.FileSize, - ReferenceType = reportMedia.ReferenceType, - Purpose = reportMedia.Purpose, - RequiresAIProcessing = reportMedia.RequiresAIProcessing - }; - - return ApiResponseBuilder.BuildSuccessResponse(response, "Report media uploaded successfully."); - }); - } -} -``` - -#### **ISnakeAIService.cs** -```csharp -public interface ISnakeAIService -{ - Task> DetectFromReportMediaAsync(Guid reportMediaId, CancellationToken ct = default); - Task> GetRecognitionResultAsync(Guid recognitionResultId, CancellationToken ct = default); - Task> DetectAsync(string imageUrl, Guid reportMediaId, CancellationToken ct = default); // Internal helper - Task IsHealthyAsync(); -} -``` - -#### **SnakeAIService.cs - Core Logic** -```csharp -public class SnakeAIService : ISnakeAIService -{ - // Main entry point for Phase 2 detection - public async Task> DetectFromReportMediaAsync(Guid reportMediaId, CancellationToken ct = default) - { - // 1. Health check AI service - if (!await IsHealthyAsync()) - return ServiceUnavailable; - - // 2. Validate ReportMedia exists - var reportMedia = await _unitOfWork.GetRepository() - .FirstOrDefaultAsync(predicate: m => m.Id == reportMediaId, cancellationToken: ct); - - if (reportMedia == null) - return NotFound; - - // 3. Call main detection with imageUrl - return await DetectAsync(reportMedia.MediaUrl, reportMediaId, ct); - } - - // Core AI processing with species mapping (V3 Logic) - public async Task> DetectAsync(string imageUrl, Guid reportMediaId, CancellationToken ct = default) - { - try - { - // 1. Call AI Service - var result = await _api.DetectByUrlAsync(request); - - // 2. Get Active AI Model - var activeModel = await _unitOfWork.GetRepository() - .FirstOrDefaultAsync(predicate: m => m.IsActive && m.IsDefault, cancellationToken: ct); - - // 3. Species Mapping Logic (V3) - var results = new List(); - foreach (var detection in result.Detections) - { - var detectionResult = new DetectionResult - { - Ai = new AiDetection - { - ClassId = detection.ClassId, - ClassName = detection.ClassName, - Confidence = detection.Confidence, - BBox = new SnakeBBox { ... } - } - }; - - // Map YOLO class to SnakeSpecies - if (activeModel != null) - { - var mapping = await _unitOfWork.GetRepository() - .FirstOrDefaultAsync( - predicate: m => m.AIModelId == activeModel.Id && - m.YoloClassName == detection.ClassName && - m.IsActive, - include: q => q.Include(m => m.SnakeSpecies) - .ThenInclude(s => s.SpeciesVenoms) - .ThenInclude(v => v.FirstAidGuideline), - cancellationToken: ct); - - if (mapping?.SnakeSpecies != null) - { - var species = mapping.SnakeSpecies; - // Fallback logic for FirstAidGuidelineOverride... - detectionResult.Snake = species; - } - } - results.Add(detectionResult); - } - - // 4. Persist Recognition Result (Logic kept similar...) - // ... - - // 5. Return enriched response (V3 Structure) - var response = new SnakeDetectionResponse - { - Metadata = new AiMetadata - { - ModelVersion = result.ModelVersion, - DetectionCount = result.Detections.Count, - // ... - }, - Results = results, - RecognitionResultId = recognitionResultId - }; - - return ApiResponseBuilder.BuildSuccessResponse(response, "Snake detection completed successfully."); - } - catch (Exception ex) - { - // Error handling... - return ApiResponseBuilder.CreateResponse(...); - } - } - - // Get historical recognition results - public async Task> GetRecognitionResultAsync(Guid recognitionResultId, CancellationToken ct = default) - { - var recognitionResult = await _unitOfWork.GetRepository() - .FirstOrDefaultAsync( - predicate: r => r.Id == recognitionResultId, - include: query => query - .Include(r => r.ReportMedia) - .Include(r => r.AIModel) - .Include(r => r.DetectedSpecies), - cancellationToken: ct); - - if (recognitionResult == null) - return NotFound; - - // Build response from saved data - var response = new SnakeDetectionResponse - { - Metadata = new AiMetadata { ... }, - RecognitionResultId = recognitionResult.Id, - Results = new List - { - new DetectionResult - { - Ai = new AiDetection { ClassName = recognitionResult.YoloClassName, ... }, - Snake = recognitionResult.DetectedSpecies - } - } - }; - - return ApiResponseBuilder.BuildSuccessResponse(response, "Recognition result retrieved successfully."); - } -} -``` - ---- - -### **3. Request/Response Models** - -#### **UploadReportMediaRequest.cs** -```csharp -public class UploadReportMediaRequest -{ - [Required] - public IFormFile File { get; set; } = default!; - - [Required] - public Guid ReferenceId { get; set; } -} -``` - -// SnakeDetectionRequest.cs (Removed - moved to Path Param) - -#### **ReportMediaResponse.cs** -```csharp -public class ReportMediaResponse -{ - public Guid Id { get; set; } - public string MediaUrl { get; set; } = string.Empty; - public string FileName { get; set; } = string.Empty; - public string ContentType { get; set; } = string.Empty; - public long FileSize { get; set; } - public MediaReferenceType ReferenceType { get; set; } - public MediaPurpose Purpose { get; set; } - public bool RequiresAIProcessing { get; set; } -} -``` - -#### **SnakeDetectionResponse.cs** -```csharp -public class SnakeDetectionResponse -{ - [JsonPropertyName("ai_metadata")] - public AiMetadata Metadata { get; set; } - - [JsonPropertyName("results")] - public List Results { get; set; } = new(); - - [JsonPropertyName("recognition_result_id")] - public Guid? RecognitionResultId { get; set; } -} - -public class AiMetadata -{ - [JsonPropertyName("model_version")] - public string? ModelVersion { get; set; } - // ... ImageWidth, ImageHeight, DetectionCount, Warnings -} - -public class DetectionResult -{ - [JsonPropertyName("ai_detection")] - public AiDetection Ai { get; set; } - - [JsonPropertyName("snake")] - public SnakeSpecies? Snake { get; set; } -} - -public class AiDetection -{ - [JsonPropertyName("class_id")] - public int ClassId { get; set; } - // ... ClassName, Confidence, BBox -} -``` - ---- - -## 🔄 FLOW DIAGRAM - -```mermaid -sequenceDiagram - participant Client as Frontend - participant MC as MediaController - participant MS as MediaService - participant CS as CloudinaryService - participant DC as SnakeDetectionController - participant SS as SnakeAIService - participant AI as SnakeAI FastAPI - participant DB as Database - - Note over Client,DB: Phase 2 Two-Step Detection Flow - - rect rgb(240, 248, 255) - Note over Client,DB: Step 1: Upload Media - Client->>MC: POST /api/media/report (file, referenceId) - MC->>MS: UploadReportMediaAsync() - MS->>CS: UploadImageAsync(file, user, "report-media") - CS-->>MS: CloudinaryUploadResult - MS->>DB: Save ReportMedia entity - DB-->>MS: ReportMedia.Id - MS-->>MC: ReportMediaResponse - MC-->>Client: { Id, MediaUrl, ... } - end - - rect rgb(240, 255, 240) - Note over Client,DB: Step 2: Detect Snake - Client->>DC: POST /api/detection/detect/{reportMediaId} - DC->>SS: DetectFromReportMediaAsync(reportMediaId) - SS->>SS: IsHealthyAsync() - SS->>DB: Get ReportMedia by ID - DB-->>SS: ReportMedia.MediaUrl - SS->>AI: DetectByUrlAsync(mediaUrl) - AI-->>SS: YOLO Detections - SS->>DB: Get AIModel + AISnakeClassMapping - DB-->>SS: Species mapping data - SS->>SS: Enrich detections with species info - SS->>DB: Save SnakeAIRecognitionResult - SS->>DB: Update ReportMedia.IsProcessed = true - SS-->>DC: SnakeDetectionResponse (enriched) - DC-->>Client: { Detections, SpeciesInfo, RecognitionResultId } - end - - rect rgb(255, 248, 240) - Note over Client,DB: Optional: Get Historical Results - Client->>DC: GET /api/detection/{recognitionResultId} - DC->>SS: GetRecognitionResultAsync(id) - SS->>DB: Get SnakeAIRecognitionResult + includes - DB-->>SS: Full recognition data - SS-->>DC: SnakeDetectionResponse (from saved data) - DC-->>Client: Historical detection result - end -``` - ---- - -## ✅ KEY IMPROVEMENTS - -### **1. Service Layer Pattern Compliance** -- **Before**: Controllers directly injected `IUnitOfWork` -- **After**: Controllers only inject `IMediaService` và `ISnakeAIService` -- **Benefit**: Proper separation of concerns, better testability - -### **2. Two-Step Flow Implementation** -- **Step 1**: Upload Media → Create ReportMedia entity -- **Step 2**: Detect Snake → Reference existing ReportMedia -- **Benefit**: All detection results are properly tracked in database - -### **3. Species Mapping Integration** -- **YOLO Detection**: Raw class names from AI model -- **Species Enrichment**: Mapped to SnakeSpecies with Vietnamese names, venom info, risk level -- **Database Persistence**: SnakeAIRecognitionResult với full mapping info - -### **4. Error Handling & User Context** -- **Fixed NullReferenceException**: CloudinaryService now receives proper `ClaimsPrincipal` -- **Transaction Management**: Atomic operations with proper rollback -- **Failed Recognition Tracking**: Even failed detections are persisted for analysis - -### **5. Historical Results Query** -- **Endpoint**: `GET /api/detection/{recognitionResultId}` -- **Use Case**: Retrieve previously saved detection results -- **Data**: Full species information từ database relationships - ---- - -## 🏗️ ARCHITECTURE BENEFITS - -1. **Scalability**: Service layer có thể extend với business logic phức tạp -2. **Maintainability**: Clear separation giữa API, Business Logic, và Data Access -3. **Testability**: Services có thể mock independent từ controllers -4. **Traceability**: Mọi detection đều có audit trail trong database -5. **Performance**: Species mapping chỉ thực hiện một lần và cache trong SnakeAIRecognitionResult - -**Next Phase Readiness**: Codebase đã sẵn sàng cho Phase 3 development với proper foundation. \ No newline at end of file diff --git a/docs/01-flows/P1-emergency/S1-identified/snake-detection/snake-detection.usageguide.md b/docs/01-flows/P1-emergency/S1-identified/snake-detection/snake-detection.usageguide.md deleted file mode 100644 index 5843ac7f..00000000 --- a/docs/01-flows/P1-emergency/S1-identified/snake-detection/snake-detection.usageguide.md +++ /dev/null @@ -1,489 +0,0 @@ -# Snake Detection - Usage Guide - -> **Loại file:** `usageguide.md` - Hướng dẫn sử dụng API/chức năng sau khi implement -> **Timeline:** Phase 2 Implementation ✅ COMPLETED -> **Target Audience:** Frontend Developer, Mobile Developer, QA, API Integration - ---- - -## 🎯 OVERVIEW - -Phase 2 Snake Detection cung cấp **Two-Step Flow** để nhận diện rắn với species mapping: - -1. **Step 1**: Upload media và tạo ReportMedia entity -2. **Step 2**: Detect snake từ ReportMedia với species enrichment từ SnakeLibs - -**Key Benefits:** -- ✅ Kết quả được lưu lịch sử trong database -- ✅ Species mapping với tên tiếng Việt, thông tin độc tính -- ✅ Proper audit trail cho mọi detection requests -- ✅ Historical results query support - ---- - -## 📋 PREREQUISITES - -``` -### Supported File Types -- **Extensions**: `.jpg`, `.jpeg`, `.png`, `.webp` -- **Max Size**: 10MB -- **Purpose**: SnakeIdentification (default) ---- - -## 🔄 FLOW - -### **Step 1: Upload Report Media** - -Upload ảnh và tạo ReportMedia entity cho AI processing. - -#### **Endpoint** -``` -POST /api/media/report -``` - -#### **Request Parameters** - -**Query Parameters:** -| Parameter | Type | Required | Default | Description | -|-----------|------|----------|---------|-------------| -| `type` | `MediaReferenceType` | ✅ | - | `CommunityReport`, `SnakebiteIncident`, etc. | -| `purpose` | `MediaPurpose` | ❌ | `SnakeIdentification` | Purpose of the media | - -**Form Data:** -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `File` | `IFormFile` | ✅ | Image file (jpg, jpeg, png, webp) | -| `ReferenceId` | `Guid` | ✅ | ID of parent entity (IncidentId, ReportId, etc.) | - -``` - -#### **Response Example** -```json -{ - "success": true, - "message": "Report media uploaded successfully.", - "data": { - "id": "123e4567-e89b-12d3-a456-426614174000", - "mediaUrl": "https://res.cloudinary.com/snakeaid/image/upload/v1699123456/report-media/userid/filename.jpg", - "fileName": "snake_photo.jpg", - "contentType": "image/jpeg", - "fileSize": 2048576, - "referenceType": "CommunityReport", - "purpose": "SnakeIdentification", - "requiresAIProcessing": true - }, - "statusCode": 200 -} -``` - ---- - -### **Step 2: Detect Snake Species** - -Nhận diện rắn từ ReportMedia đã upload, bao gồm species mapping. - -#### **Endpoint** -``` -POST /api/detection/detect/{reportMediaId} -``` - -#### **Request Body** -*None* (ID is passed in URL path) - -#### **Response Example** -```json -{ - "status_code": 200, - "message": "Snake detection completed successfully.", - "is_success": true, - "data": { - "ai_metadata": { - "model_version": "7", - "image_width": 227, - "image_height": 222, - "detection_count": 1, - "warnings": { - "blur": 0, - "brightness": 0.6604, - "too_small": 0.3063 - } - }, - "results": [ - { - "ai_detection": { - "class_id": 3, - "class_name": "ho_mang_chua", - "confidence": 0.8951632, - "bbox": { - "x1": 9, - "y1": 40, - "x2": 189, - "y2": 198 - } - }, - "snake": { - "id": 3, - "scientificName": "Ophiophagus hannah", - "slug": "ran-ho-mang-chua", - "commonName": "Rắn Hổ Mang Chúa", - "imageUrl": "https://vietnamsnakes.com/storage/snakes/species/55/1737107747_0.jpg", - "description": "Loài rắn độc dài nhất thế giới, cực kỳ nguy hiểm với lượng nọc độc khổng lồ.", - "identificationSummary": "Kích thước khổng lồ (4-6m), Vảy đầu lớn, Cổ phình mang hẹp, vân chữ V ngược ở cổ.", - "primaryVenomType": "Neurotoxic", - "identification": { - "physicalTraits": [ - "Kích thước khổng lồ (4-6m)", - "Cặp vảy chẩm hình cánh bướm, nằm ở ngay phía sau vảy đầu", - "Phình mang hẹp dài", - "Màu đen, nâu hoặc vàng chì" - ], - "behaviors": [ - "Chủ động tấn công nếu bị kích động", - "Có khả năng rướn cao thân mình", - "Là một loài rắn thông minh, sẽ quan sát và phản ứng." - ], - "habitat": "Rừng rậm, nương rẫy, gần nguồn nước" - }, - "symptomsByTime": [ - { - "timeRange": "0 - 15 phút", - "signs": ["Đau nhức", "Chóng mặt", "Hoa mắt"], - "isCritical": true - }, - { - "timeRange": "30 - 60 phút", - "signs": ["Hôn mê", "Suy hô hấp cấp", "Tử vong nhanh"], - "isCritical": true - } - ], - "firstAidGuidelineOverride": { - "mode": "Append", - "steps": [ - "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." - ] - }, - "riskLevel": 10, - "isVenomous": true, - "speciesVenoms": [ - { - "venomType": { - "name": "Độc thần kinh", - "description": "Nọc độc chủ yếu ảnh hưởng đến hệ thần kinh...", - "firstAidGuideline": { - "name": "Sơ cứu Độc thần kinh", - "content": { - "steps": [ - { "text": "Di chuyển nhẹ nhàng...", "mediaUrl": "..." }, - { "text": "Gọi hỗ trợ y tế...", "mediaUrl": "..." } - ], - "dos": [{ "text": "Kiểm tra mạch...", "mediaUrl": "" }], - "donts": [{ "text": "Không tự ý tháo băng...", "mediaUrl": "..." }] - }, - "summary": "Ngăn chặn liệt hô hấp bằng cách băng cố định đúng cách." - } - } - } - ] - } - } - ], - "recognition_result_id": "d08d9855-00a7-4e1f-82bb-9935e06044bb" - }, - "error": null -} -``` - -#### **Key Response Fields** - -| Section | Field | Description | -|:---|:---|:---| -| **Root** | `status_code` | Tiêu chuẩn HTTP status (200, 400, 500...) | -| | `is_success` | Cờ báo hiệu request thành công hay không | -| | `data` | Payload chính chứa kết quả nhận diện | -| **AI Metadata** | `model_version` | Phiên bản AI Model đang chạy | -| | `warnings` | Cảnh báo chất lượng ảnh (`blur`, `brightness`, `too_small`) | -| **Detections** | `results` | Mảng các đối tượng được tìm thấy trong ảnh | -| | `ai_detection` | Thông tin kỹ thuật từ AI (Tên lớp, Confidence, BBox) | -| | `snake` | Dữ liệu đầy đủ về loài rắn được map từ database | -| **Snake Info** | `primaryVenomType` | Loại nọc độc chính (Neurotoxic, Hemotoxic...) | -| | `identification` | Đặc điểm nhận dạng (Ngoại hình, Hành vi, Môi trường) | -| | `symptomsByTime` | Các triệu chứng theo mốc thời gian | -| | `speciesVenoms` | Chi tiết các loại nọc độc và **Hướng dẫn sơ cứu tương ứng** | -| **Guidelines** | `firstAidGuideline` | Chứa các bước (`steps`), việc nên làm (`dos`), không nên làm (`donts`) kèm `mediaUrl` | - ---- - -### **Step 3: Get Historical Results** (Optional) - -Retrieve previously saved detection results by RecognitionResultId. - -#### **Endpoint** -``` -GET /api/detection/{recognitionResultId} -``` - ---- - -## 🎨 FRONTEND INTEGRATION EXAMPLES -### **Flutter/Dart Example** -```dart -import 'dart:io'; -import 'package:http/http.dart' as http; -import 'dart:convert'; - -class SnakeDetectionService { - final String baseUrl = 'https://api.snakeaid.com'; - final String _accessToken; - - SnakeDetectionService(this._accessToken); - - Future> uploadMedia(File file, String referenceId) async { - var request = http.MultipartRequest('POST', Uri.parse('$baseUrl/api/media/report?type=CommunityReport')); - request.headers['Authorization'] = 'Bearer $_accessToken'; - - request.files.add(await http.MultipartFile.fromPath('File', file.path)); - request.fields['ReferenceId'] = referenceId; - - var response = await request.send(); - var responseBody = await response.stream.bytesToString(); - - if (response.statusCode == 200) { - return json.decode(responseBody); - } else { - throw Exception('Upload failed: ${response.statusCode}'); - } - } - - Future> detectSnake(String reportMediaId) async { - final response = await http.post( - Uri.parse('$baseUrl/api/detection/detect/$reportMediaId'), - headers: { - 'Authorization': 'Bearer $_accessToken', - }, - ); - - if (response.statusCode == 200) { - return json.decode(response.body); - } else { - throw Exception('Detection failed: ${response.statusCode}'); - } - } - - Future> detectSnakeFromFile(File file, String referenceId) async { - // Step 1: Upload - final uploadResult = await uploadMedia(file, referenceId); - final mediaId = uploadResult['data']['id']; - - // Step 2: Detect - final detectionResult = await detectSnake(mediaId); - - return { - 'media': uploadResult['data'], - 'detection': detectionResult['data'], - }; - } -} - -// Usage in Flutter widget -class SnakeDetectionWidget extends StatefulWidget { - @override - _SnakeDetectionWidgetState createState() => _SnakeDetectionWidgetState(); -} - -class _SnakeDetectionWidgetState extends State { - final SnakeDetectionService _service = SnakeDetectionService(accessToken); - bool _isLoading = false; - Map? _result; - - Future _detectFromImage(File imageFile) async { - setState(() { - _isLoading = true; - }); - - try { - final result = await _service.detectSnakeFromFile(imageFile, reportId); - setState(() { - _result = result; - }); - } catch (e) { - // Handle error - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Detection failed: $e')), - ); - } finally { - setState(() { - _isLoading = false; - }); - } - } - - @override - Widget build(BuildContext context) { - return Column( - children: [ - if (_isLoading) - CircularProgressIndicator() - else if (_result != null) - DetectionResultWidget(result: _result!), - // File picker and detect button... - ], - ); - } -} -``` - ---- - -## 🚨 ERROR HANDLING - -### **Common Error Responses** - -#### **400 Bad Request** -```json -{ - "success": false, - "message": "Validation failed", - "errors": { - "File": ["The File field is required."], - "ReferenceId": ["The ReferenceId field is required."] - }, - "statusCode": 400, - "errorCode": "VALIDATION_FAILED" -} -``` - -#### **404 Not Found** -```json -{ - "success": false, - "message": "ReportMedia not found.", - "statusCode": 404, - "errorCode": "NOT_FOUND" -} -``` - -#### **503 Service Unavailable** -```json -{ - "success": false, - "message": "Snake detection service is currently unavailable. Please try again later.", - "statusCode": 503, - "errorCode": "SERVICE_UNAVAILABLE" -} -``` - -#### **500 Internal Server Error** -```json -{ - "success": false, - "message": "Snake detection failed. Please try again later.", - "statusCode": 500, - "errorCode": "DETECTION_FAILED" -} -``` - -### **Error Handling Best Practices** - -```javascript -const handleApiCall = async (apiFunction) => { - try { - const result = await apiFunction(); - return { success: true, data: result }; - } catch (error) { - console.error('API Error:', error); - - if (error.response) { - // Server responded with error status - const { statusCode, errorCode, message } = error.response.data; - - switch (statusCode) { - case 400: - return { success: false, error: 'Invalid request. Please check your input.' }; - case 404: - return { success: false, error: 'Resource not found.' }; - case 503: - return { success: false, error: 'Service temporarily unavailable. Please try again later.' }; - default: - return { success: false, error: message || 'An unexpected error occurred.' }; - } - } else { - // Network error - return { success: false, error: 'Network error. Please check your connection.' }; - } - } -}; -``` - ---- - -## 🧪 TESTING WITH POSTMAN - -### **Collection Setup** - -**Environment Variables:** -```json -{ - "baseUrl": "https://api.snakeaid.com", - "accessToken": "your-jwt-token-here", - "reportId": "550e8400-e29b-41d4-a716-446655440000" -} -``` - -### **Test Sequence** - -1. **Upload Test Image** - - Method: `POST` - - URL: `{{baseUrl}}/api/media/report?type=CommunityReport` - - Headers: `Authorization: Bearer {{accessToken}}` - - Body: `form-data` with `File` (snake image) and `ReferenceId: {{reportId}}` - - Test: Save `response.data.id` to `reportMediaId` - -2. **Detect Snake** - - Method: `POST` - - URL: `{{baseUrl}}/api/detection/detect/{{reportMediaId}}` - - Headers: `Authorization: Bearer {{accessToken}}` - - Test: Save `response.data.recognitionResultId` to `recognitionResultId` - -3. **Get Historical Result** - - Method: `GET` - - URL: `{{baseUrl}}/api/detection/{{recognitionResultId}}` - - Headers: `Authorization: Bearer {{accessToken}}` - ---- - -## 🔍 SPECIES INFORMATION FIELDS - -The API returns enriched species information when a mapping exists: - -| Field | Type | Description | Example | -|-------|------|-------------|---------| -| `speciesId` | `int?` | Database ID of the species | `15` | -| `speciesName` | `string?` | Vietnamese common name | `"Rắn hổ mang chúa"` | -| `scientificName` | `string?` | Scientific binomial name | `"Ophiophagus hannah"` | -| `isVenomous` | `bool?` | Whether species is venomous | `true` | -| `riskLevel` | `float?` | Danger level (0-10 scale) | `9.5` | - -**Mapping Logic:** -- AI Model detects YOLO class (e.g., "cobra") -- System looks up `AISnakeClassMapping` for active model -- If mapping exists → species info populated -- If no mapping → species fields are `null` - ---- - -## 📊 PERFORMANCE NOTES - -### **Typical Response Times** -- **Media Upload**: 2-5 seconds (depends on image size and Cloudinary) -- **Snake Detection**: 3-8 seconds (depends on AI service load) -- **Historical Results**: 100-500ms (database query only) - -### **File Size Recommendations** -- **Optimal**: 1-3MB images (balance between quality and speed) -- **Maximum**: 10MB (enforced by validation) -- **Resolution**: 800x600 to 1920x1080 works best for AI model - -### **Caching Strategy** -- Recognition results are permanently cached in database -- Use `recognition_result_id` for quick retrieval -- No need to re-run detection for previously processed images \ No newline at end of file diff --git a/docs/01-flows/P1-emergency/S2-not-identified/_flow.md b/docs/01-flows/P1-emergency/S2-not-identified/_flow.md deleted file mode 100644 index 2d7d586a..00000000 --- a/docs/01-flows/P1-emergency/S2-not-identified/_flow.md +++ /dev/null @@ -1,47 +0,0 @@ - -# Mapping Flow P1-S2: Cứu hộ - Không nhận diện được rắn - -> **Flow ID:** P1 | **SubFlow:** S2 - Không nhận diện được rắn | **Role:** Member - -Dưới đây là các bước khi Member không nhận diện được loài rắn: - -## 1. Chọn rắn theo vị trí -**Screen:** Chọn rắn theo vị trí -**Action:** Member chọn loài rắn dựa trên vùng địa lý -**Backend Process:** -- Lấy danh sách loài rắn phổ biến theo location -- Lọc theo `SnakeSpecies.GeographicDistribution` -**Endpoint:** `GET /api/snakes?location={lat,lng}` -**Note:** Filter by distribution polygon. - -## 2. Xác nhận loài rắn -**Screen:** Xác nhận loài rắn -**Action:** Member chọn loài rắn từ danh sách -**Backend Process:** -- Lấy chi tiết loài rắn đã chọn -- Hiển thị hình ảnh và đặc điểm -**Endpoint:** `GET /api/snakes/{slug}` - -## 3. Nhận dạng qua câu hỏi -**Screen:** Nhận dạng qua câu hỏi -**Action:** Member trả lời câu hỏi để thu hẹp loài rắn -**Backend Process:** -- Sử dụng decision tree / questionnaire -- Lọc dần danh sách loài khả thi -**Endpoint:** `GET /api/snakes/identify` (cần mở rộng API) -**Note:** Input: answers to questions, Output: filtered species list. - -## 4. Chọn rắn theo vị trí (2) -**Screen:** Chọn rắn theo vị trí -**Action:** Member chọn lại loài rắn sau khi xem gợi ý -**Backend Process:** -- Tương tự bước 1-2 -**Endpoint:** `GET /api/snakes/{slug}` - -## 5. Hướng dẫn sơ cứu chung -**Screen:** Hướng dẫn sơ cứu chung -**Action:** Hiển thị hướng dẫn sơ cứu mặc định (không có loài cụ thể) -**Backend Process:** -- Lấy general first aid guidelines -- Hiển thị các bước sơ cứu phổ quát -**Endpoint:** `GET /api/first-aid/general` diff --git a/docs/01-flows/P2-catching/S1-single-snake/_flow.md b/docs/01-flows/P2-catching/S1-single-snake/_flow.md deleted file mode 100644 index 28ea346a..00000000 --- a/docs/01-flows/P2-catching/S1-single-snake/_flow.md +++ /dev/null @@ -1,98 +0,0 @@ - -# Mapping Flow P2-S1: Bắt rắn - Bắt 1 con rắn - -> **Flow ID:** P2 | **SubFlow:** S1 - Bắt 1 con rắn | **Role:** Member - -Dưới đây là các bước khi Member yêu cầu bắt 1 con rắn (non-emergency): - -## 1. Chọn số lượng rắn -**Screen:** Chọn số lượng rắn -**Action:** Member chọn "1 con rắn" -**Backend Process:** -- Khởi tạo `CatchingRequest` với quantity = 1 -**Endpoint:** `POST /api/catching/requests` -**Note:** Request body chứa quantity và initial location. - -## 2. Báo cáo 1 con rắn -**Screen:** Báo cáo 1 con rắn -**Action:** Member nhập thông tin về con rắn -**Backend Process:** -- Cập nhật chi tiết: vị trí cụ thể, kích thước ước tính, mô tả -**Endpoint:** `PUT /api/catching/requests/{id}` - -## 3. Chọn loài rắn -**Screen:** Chọn loài rắn -**Action:** Member chọn hoặc chụp ảnh để xác định loài -**Backend Process:** -- Upload ảnh nếu có -- Chọn từ danh sách hoặc AI nhận diện -**Endpoints:** -- `POST /api/media/upload-image` -- `POST /api/aivision/detect` -- `GET /api/snakes?location={lat,lng}` - -## 4. Kết quả rắn AI -**Screen:** Kết quả rắn AI -**Action:** System hiển thị kết quả nhận diện -**Backend Process:** -- Hiển thị loài, độc tính, mức độ nguy hiểm -- Lưu vào `CatchingRequest.DeclaredSpeciesId` -**Endpoint:** `GET /api/aivision/{id}` - -## 5. Xác nhận yêu cầu cứu hộ -**Screen:** Xác nhận yêu cầu cứu hộ -**Action:** Member xác nhận và submit yêu cầu bắt rắn -**Backend Process:** -- Finalize request -- Tính phí dự kiến dựa trên loài và kích thước -**Endpoint:** `PUT /api/catching/requests/{id}/confirm` -**Note:** Cần mở rộng API - thêm confirm endpoint. - -## 6. Đang tìm đội cứu hộ -**Screen:** Đang tìm đội cứu hộ -**Action:** System tìm kiếm Rescuer gần nhất -**Backend Process:** -- Query `Rescuer` trong bán kính 5km → 10km → 15km -- Filter by availability và experience -- Send notifications đến top candidates -**Endpoint:** `GET /api/catching/requests/nearby` (Rescuer side) -**Note:** Member chờ, system tự động matching. - -## 7. Theo dõi cứu hộ trực tiếp -**Screen:** Theo dõi cứu hộ trực tiếp -**Action:** Member theo dõi vị trí Rescuer trên map -**Backend Process:** -- Stream `LocationEvent` của Rescuer -- Cập nhật ETA -**Endpoints:** -- `GET /api/catching/missions/{id}/tracking` -- `GET /api/missions/{id}/tracking` (shared endpoint) - -## 8. Đội cứu hộ đã đến -**Screen:** Đội cứu hộ đã đến -**Action:** Rescuer xác nhận đã đến -**Backend Process:** -- Cập nhật `CatchingMission.Status` → `Arrived` -**Endpoint:** `PUT /api/catching/missions/{id}/status` - -## 9. Thanh toán và đánh giá -**Screen:** Thanh toán tiền còn lại và đánh giá -**Action:** Member thanh toán và đánh giá dịch vụ -**Backend Process:** -- Tính phí cuối cùng (có thể điều chỉnh nếu loài khác) -- Xử lý thanh toán qua wallet -- Nhận rating & review -**Endpoints:** -- `POST /api/wallet/deposit` -- `POST /api/catching/missions/{id}/review` (cần mở rộng) - -## 10. Thanh toán thành công -**Screen:** Thanh toán thành công -**Action:** System xác nhận thanh toán hoàn tất -**Backend Process:** -- Cập nhật `CatchingMission.Status` → `Completed` -- Phân chia thanh toán (85% Rescuer, 10% Platform, 5% Insurance) -- Lưu transaction history -**Endpoints:** -- `PUT /api/catching/missions/{id}/complete` -- `GET /api/wallet/transactions` diff --git a/docs/01-flows/P2-catching/S2-multiple-snakes/_flow.md b/docs/01-flows/P2-catching/S2-multiple-snakes/_flow.md deleted file mode 100644 index 36425a57..00000000 --- a/docs/01-flows/P2-catching/S2-multiple-snakes/_flow.md +++ /dev/null @@ -1,42 +0,0 @@ - -# Mapping Flow P2-S2: Bắt rắn - Bắt nhiều rắn (2-5 con) - -> **Flow ID:** P2 | **SubFlow:** S2 - Bắt nhiều rắn | **Role:** Member - -Dưới đây là các bước khi Member yêu cầu bắt 2-5 con rắn: - -## 1. Báo cáo 2-5 con rắn -**Screen:** Báo cáo 2-5 con rắn -**Action:** Member nhập số lượng và thông tin -**Backend Process:** -- Khởi tạo `CatchingRequest` với quantity = 2-5 -- Lấy GPS location -**Endpoint:** `POST /api/catching/requests` -**Note:** Request body: `{ quantity: 3, location: {...} }` - -## 2. Chọn loài rắn (2-5 con) -**Screen:** Chọn loài rắn (2-5 con) -**Action:** Member xác định loài cho từng con (hoặc mixed) -**Backend Process:** -- Upload nhiều ảnh (mỗi con 1 ảnh) -- AI nhận diện từng ảnh -- Cho phép chọn "mixed species" -**Endpoints:** -- `POST /api/media/upload-image` (multiple calls) -- `POST /api/aivision/detect` (per image) -- `GET /api/snakes?location={lat,lng}` - -## 3. Kết quả nhận diện nhiều rắn AI -**Screen:** Kết quả nhận diện nhiều rắn AI -**Action:** System hiển thị kết quả nhận diện cho từng con -**Backend Process:** -- Aggregate results for all snakes -- Tính tổng độ nguy hiểm và phí dự kiến -**Endpoints:** -- `GET /api/aivision/{id}` (per detection) -- `PUT /api/catching/requests/{id}` (update with species list) -**Note:** Phí tăng theo số lượng và độ nguy hiểm. - ---- - -> **Tiếp theo:** Các bước 4-10 tương tự Flow P2-S1 (Xác nhận → Tìm Rescuer → Theo dõi → Thanh toán) diff --git a/docs/01-flows/P2-catching/S3-snake-nest/_flow.md b/docs/01-flows/P2-catching/S3-snake-nest/_flow.md deleted file mode 100644 index ed08f447..00000000 --- a/docs/01-flows/P2-catching/S3-snake-nest/_flow.md +++ /dev/null @@ -1,43 +0,0 @@ - -# Mapping Flow P2-S3: Bắt rắn - Bắt cả ổ rắn - -> **Flow ID:** P2 | **SubFlow:** S3 - Bắt cả ổ rắn | **Role:** Member - -Dưới đây là các bước khi Member yêu cầu bắt cả ổ rắn: - -## 1. Báo cáo ổ rắn / Nhiều con -**Screen:** Báo cáo ổ rắn / Nhiều con -**Action:** Member báo cáo phát hiện ổ rắn -**Backend Process:** -- Khởi tạo `CatchingRequest` với type = "nest" -- Đánh dấu priority cao hơn -**Endpoint:** `POST /api/catching/requests` -**Note:** Request body: `{ type: "nest", estimatedCount: 10+, location: {...} }` - -## 2. Chọn loài rắn (ổ rắn / nhiều con) -**Screen:** Chọn loài rắn (ổ rắn / nhiều con) -**Action:** Member xác định loài rắn trong ổ -**Backend Process:** -- Upload ảnh ổ rắn -- AI nhận diện species (thường chỉ 1 loài trong 1 ổ) -**Endpoints:** -- `POST /api/media/upload-image` -- `POST /api/aivision/detect` -- `GET /api/snakes/{slug}` (species details) - -## 3. Kết quả phân tích ổ rắn -**Screen:** Kết quả phân tích ổ rắn / nhiều con -**Action:** System hiển thị kết quả phân tích -**Backend Process:** -- Hiển thị loài, ước tính số lượng -- Tính phí đặc biệt cho nest removal -- Recommend professional team -**Endpoints:** -- `GET /api/aivision/{id}` -- `PUT /api/catching/requests/{id}` -**Note:** Nest removal thường cần team chuyên nghiệp, phí cao hơn. - ---- - -> **Tiếp theo:** Các bước 4-10 tương tự Flow P2-S1 (Xác nhận → Tìm Rescuer → Theo dõi → Thanh toán) -> **Lưu ý:** Ổ rắn yêu cầu Rescuer có kinh nghiệm cao, có thể cần nhiều người. diff --git a/docs/01-flows/P2-catching/S4-common/_flow.md b/docs/01-flows/P2-catching/S4-common/_flow.md deleted file mode 100644 index 06dfba1c..00000000 --- a/docs/01-flows/P2-catching/S4-common/_flow.md +++ /dev/null @@ -1,40 +0,0 @@ - -# Mapping Flow P2-S4: Bắt rắn - Các màn hình chung - -> **Flow ID:** P2 | **SubFlow:** S4 - Màn hình chung | **Role:** Member/System - -Dưới đây là các màn hình dùng chung trong Flow P2: - -## 1. Xác nhận cảnh báo cộng đồng -**Screen:** Xác nhận cảnh báo cộng đồng -**Action:** System hỏi Member có muốn cảnh báo cộng đồng không -**Backend Process:** -- User chọn Yes → Tạo `CommunityReport` -- Gửi notification đến users trong khu vực -- Cập nhật heatmap data -**Endpoints:** -- `POST /api/community/reports` -- `GET /api/community/reports?lat={lat}&lng={lng}` (nearby sightings) -**Note:** Miễn phí, giúp cảnh báo cộng đồng về rắn trong khu vực. - -## 2. Không tìm thấy đội cứu hộ -**Screen:** Không tìm thấy đội cứu hộ -**Action:** System thông báo không có Rescuer khả dụng -**Backend Process:** -- Sau 5 phút tìm kiếm không có kết quả -- Đề xuất các lựa chọn thay thế -**Suggestions:** -- Gọi trung tâm kiểm soát động vật địa phương -- Gọi 115 (nếu khẩn cấp) -- Đăng lại request sau -**Endpoint:** `PUT /api/catching/requests/{id}/cancel` -**Note:** Log reason = "no_rescuer_available" - -## 3. Hủy cứu hộ -**Screen:** Hủy cứu hộ -**Action:** Member hủy yêu cầu bắt rắn -**Backend Process:** -- Cập nhật `CatchingRequest.Status` → `Cancelled` -- Nếu đã có Rescuer accept → thông báo và hoàn phí (nếu có) -**Endpoint:** `PUT /api/catching/requests/{id}/cancel` -**Note:** Có thể phạt phí nếu hủy sau khi Rescuer đã di chuyển. diff --git a/docs/01-flows/Snake Backend Plans.csv b/docs/01-flows/Snake Backend Plans.csv deleted file mode 100644 index c4b7975c..00000000 --- a/docs/01-flows/Snake Backend Plans.csv +++ /dev/null @@ -1,56 +0,0 @@ -Flow ID,Role,Flow Name,SubFlow,Screen,Figma Title,Status,Done Endpoint,UI Interactive,Backend Process,Entity Graph,HTTPS,Endpoint,Expected Output,System Response,Notes / Bugs Found,Validation Type,Test Data Example -P1,Member,Cứu hộ,Nhận diện được rắn,1-1,Trang chủ,⚠️,⚠️,Member bấm SOS,"- Tạo SnakebiteIncident -- Tạo RescuerRequest","SnakebiteIncident -RescuerRequest",POST,/api/incidents/sos,,,,,— -P1,Member,Cứu hộ,Nhận diện được rắn,1-2,Cảnh báo khẩn cấp,⚠️,⚠️,Member thấy các Rescuer khác trên map,- Theo dõi vị trí Rescuer nhận nhiệm vụ,"RescueMission -LocationEvent",GET,/api/missions/{id}/tracking,,,,, -P1,Member,Cứu hộ,Nhận diện được rắn,1-3,chụp ảnh rắn để AI nhận diện,⚠️,⚠️,Member chụp ảnh rắn,"- Lưu vào Cloudinary -- Lấy url call api YOLO - + Nhận reponse -> fill vào feild - + Querry ngược về loài rắn","Report_Media.MediaUrl -. -SnakeAIRecognitionResult.AIModelId YoloClassName Confidence -AiModel -> AISnakeClassMapping -> SnakeSpecies.CommonName => SnakeAIRecognitionResult.DetectedSpeciesId",POST,/api/detection/detect,,,,, -P1,Member,Cứu hộ,Nhận diện được rắn,1-4,kết quả nhận diện loài rắn bằng AI,⚠️,⚠️,System trả kết quả sau khi chụp,"- Lấy snake_specie từ model -- Lấy thông tin ""Cần làm ngay"" từ thư viện rắn - + Check: `FirstAidGuidelineOverride` - + Nếu ko có thì tìm:","SnakeSpecies -. -SnakeSpecies.FirstAidGuidelineOverride -SnakeSpecies -> SpeciesVenom -> VenomType -> FirstAidGuideline.Content",RESPONSE,/api/detection/detect,,,,, -P1,Member,Cứu hộ,Nhận diện được rắn,1-5,hướng dẫn sơ cứu theo loài,⚠️,⚠️,User bấm xem cách sơ cứu,- Lấy hướng dẫn sơ cứu,SnakeSpecies.SpeciesVenom.VenomType.FirstAidGuideline,GET,/api/first-aid/species/{slug},,,,, -P1,Member,Cứu hộ,Nhận diện được rắn,1-6,nhập triệu chứng và chụp vết cắn,⚠️,⚠️,User nhập các yếu tố về vết cắn,"- Fill jsonB triệu chứng -- Tính severity point cho incident","SnakebiteIncident.SeverityLevel -SnakebiteIncident.SymptomsReport",PUT,/api/incidents/{id}/symptoms,,,,, -P1,Member,Cứu hộ,Nhận diện được rắn,1-7,hướng dẫn sơ cứu,⚠️,⚠️,System hiện thông tin triệu chứng,"- Lấy điểm mức độ nghiêm trọng -- Lấy thông tin triệu chứng ","SnakebiteIncident.SeverityLevel -SnakebiteIncident.SymptomsReport",GET,/api/incidents/{id},,,,, -P1,Member,Cứu hộ,Nhận diện được rắn,1-8,theo dõi sos khẩn cấp,⚠️,⚠️,,,,,,,,,, -P1,Member,Cứu hộ,Nhận diện được rắn,1-9,cứu hộ đã đến,⚠️,⚠️,,,,,,,,,, -P1,Member,Cứu hộ,Nhận diện được rắn,1-10,thanh toán và đánh giá sau cấp cứu,⚠️,⚠️,,,,,,,,,, -P1,Member,Cứu hộ,Nhận diện được rắn,1-11,thanh toán thành công,⚠️,⚠️,,,,,,,,,, -P1,Member,Cứu hộ,Nhận diện được rắn,1-12,bản đồ tìm kiếm bệnh viện có huyết thanh,⚠️,⚠️,,,,,,,,,, -P1,Member,Cứu hộ,Không nhận điện,2-1,chọn rắn theo vị trí,⚠️,⚠️,,,,,,,,,, -P1,Member,Cứu hộ,Không nhận điện,2-2,xác nhận loài rắn,⚠️,⚠️,,,,,,,,,, -P1,Member,Cứu hộ,Không nhận điện,2-3,nhận dạng qua câu hỏi,⚠️,⚠️,,,,,,,,,, -P1,Member,Cứu hộ,Không nhận điện,2-4,chọn rắn theo vị trí,⚠️,⚠️,,,,,,,,,, -P1,Member,Cứu hộ,Không nhận điện,2-5,hướng dẫn sơ cứu chung,⚠️,⚠️,,,,,,,,,, -P2,Member,Bắt rắn,Bắt 1 con rắn,3-1,Chọn số lượng rắn,⚠️,⚠️,,,,,,,,,, -P2,Member,Bắt rắn,Bắt 1 con rắn,3-2,Báo cáo 1 con rắn,⚠️,⚠️,,,,,,,,,, -P2,Member,Bắt rắn,Bắt 1 con rắn,3-3,Chọn loài rắn,⚠️,⚠️,,,,,,,,,, -P2,Member,Bắt rắn,Bắt 1 con rắn,3-4,Kết quả rắn AI,⚠️,⚠️,,,,,,,,,, -P2,Member,Bắt rắn,Bắt 1 con rắn,3-5,Xác nhận yêu cầu cứu hộ,⚠️,⚠️,,,,,,,,,, -P2,Member,Bắt rắn,Bắt 1 con rắn,3-6,Đang tìm đội cứu hộ,⚠️,⚠️,,,,,,,,,, -P2,Member,Bắt rắn,Bắt 1 con rắn,3-7,Theo dõi cứu hộ trực tiếp,⚠️,⚠️,,,,,,,,,, -P2,Member,Bắt rắn,Bắt 1 con rắn,3-8,Đội cứu hộ đã đến,⚠️,⚠️,,,,,,,,,, -P2,Member,Bắt rắn,Bắt 1 con rắn,3-9,Thanh toán tiền còn lại và đánh giá,⚠️,⚠️,,,,,,,,,, -P2,Member,Bắt rắn,Bắt 1 con rắn,3-10,thanh toán thành công,⚠️,⚠️,,,,,,,,,, -P2,Member,Bắt rắn,Bắt nhiều rắn,4-1,Báo cáo 2-5 Con rắn,⚠️,⚠️,,,,,,,,,, -P2,Member,Bắt rắn,Bắt nhiều rắn,4-2,Chọn loài rắn (2-5 con),⚠️,⚠️,,,,,,,,,, -P2,Member,Bắt rắn,Bắt nhiều rắn,4-3,Kết quả nhận diện nhiều rắn AI,⚠️,⚠️,,,,,,,,,, -P2,Member,Bắt rắn,Bắt cả ổ rắn,5-1,Báo cáo ổ rắn / Nhiều con,⚠️,⚠️,,,,,,,,,, -P2,Member,Bắt rắn,Bắt cả ổ rắn,5-2,Chọn loài rắn (ổ rắn / nhiều con),⚠️,⚠️,,,,,,,,,, -P2,Member,Bắt rắn,Bắt cả ổ rắn,5-3,Kết quả phân tích ổ rắn / nhiều con,⚠️,⚠️,,,,,,,,,, -P2,Member,Bắt rắn,,6-1,xác nhận cảnh báo cộng đồng,⚠️,⚠️,,,,,,,,,, -P2,Member,Bắt rắn,,6-2,không tìm thấy đội cứu hộ,⚠️,⚠️,,,,,,,,,, -P2,Member,Bắt rắn,,6-3,hủy cứu hộ,⚠️,⚠️,,,,,,,,,, \ No newline at end of file diff --git a/docs/02-layers/ai/SankeAi.introduction.md b/docs/02-layers/ai/SankeAi.introduction.md deleted file mode 100644 index b9af1d5c..00000000 --- a/docs/02-layers/ai/SankeAi.introduction.md +++ /dev/null @@ -1,184 +0,0 @@ -# SnakeAI Model Endpoint - API Reference - -This document provides detailed technical documentation for the SnakeAI Model Endpoint (FastAPI). It is intended for backend developers implementing clients for this service. - -## Base URL - -By default, the service runs on port 8000. -`http://localhost:8000` - -## Error Handling - -All errors return a standard JSON structure. - -**Schema:** -```json -{ - "error": { - "code": "STRING", - "message": "STRING", - "details": "ANY" - } -} -``` - -**Common Error Codes:** -| Status Code | Error Code | Description | -| :--- | :--- | :--- | -| 400 | `VALIDATION_ERROR` | Invalid input parameters. | -| 400 | `INVALID_CONTENT_TYPE` | Uploaded file is not an image. | -| 400 | `INVALID_IMAGE` | Image data could not be decoded. | -| 400 | `INVALID_BASE64` | Invalid Base64 string. | -| 400 | `URL_FETCH_ERROR` | Failed to fetch image from URL. | -| 413 | `PAYLOAD_TOO_LARGE` | Upload exceeds `MAX_UPLOAD_MB`. | -| 413 | `DOWNLOAD_TOO_LARGE` | URL download exceeds limit. | -| 429 | `RATE_LIMITED` | Request rate limit exceeded (per IP). | -| 429 | `QUEUE_FULL` | Server concurrency limit reached. | -| 503 | `MODEL_NOT_LOADED` | Model failed to load at startup. | -| 504 | `URL_FETCH_TIMEOUT` | Upstream image download timed out. | - ---- - -## Data Structures - -### Detection Object -Represents a single detected object (snake). - -```json -{ - "class_id": 0, - "class_name": "snake_species_name", - "confidence": 0.85, - "bbox": { - "x1": 100, - "y1": 200, - "x2": 300, - "y2": 400 - } -} -``` - -### Response Object (Success) -Returned by all detect endpoints. - -```json -{ - "model_version": "snake-yolo12-v1.0", - "image_width": 1280, - "image_height": 720, - "warnings": { - "blur": 0.05, - "brightness": 0.45, - "too_small": 0.0 - }, - "detections": [ ... ], - "saved_image_path": "/data/saved_images/uuid.jpg" // Optional, present if save_image=true -} -``` - ---- - -## Endpoints - -### 1. Detect from File -Upload an image file directly via `multipart/form-data`. - -- **Method:** `POST` -- **Path:** `/detect/file` -- **Content-Type:** `multipart/form-data` - -**Parameters (Query & Form):** -| Parameter | Type | In | Default | Description | -| :--- | :--- | :--- | :--- | :--- | -| `image` | File | Form | Required | Image file (JPEG, PNG, etc). | -| `imgsz` | int | Query | 640 | Inference size (longest side). | -| `conf` | float | Query | 0.25 | Confidence threshold (0.0 - 1.0). | -| `iou` | float | Query | 0.5 | NMS IoU threshold (0.0 - 1.0). | -| `topk` | int | Query | 100 | Max detections to return. | -| `save_image` | bool | Query | false | Save processed image to disk. | - -**Example Request:** -```bash -curl -X POST "http://localhost:8000/detect/file?imgsz=640&conf=0.25" \ - -F "image=@/path/to/cobra.jpg" -``` - ---- - -### 2. Detect from Base64 -Send a raw Base64 string in a JSON body. - -- **Method:** `POST` -- **Path:** `/detect/base64` -- **Content-Type:** `application/json` - -**Request Body Schema:** -```json -{ - "image_base64": "base64_string_here", - "imgsz": 640, - "conf": 0.25, - "iou": 0.5, - "topk": 100, - "save_image": false -} -``` - -**Example Request:** -```bash -curl -X POST "http://localhost:8000/detect/base64" \ - -H "Content-Type: application/json" \ - -d '{ - "image_base64": "/9j/4AAQSkZJRg...", - "conf": 0.5 - }' -``` - ---- - -### 3. Detect from URL -Server downloads image from a public URL. - -- **Method:** `POST` -- **Path:** `/detect/url` -- **Content-Type:** `application/json` - -**Request Body Schema:** -```json -{ - "image_url": "https://example.com/snake.jpg", - "imgsz": 640, - "conf": 0.25, - "iou": 0.5, - "topk": 100, - "save_image": false -} -``` - -**Example Request:** -```bash -curl -X POST "http://localhost:8000/detect/url" \ - -H "Content-Type: application/json" \ - -d '{ - "image_url": "https://upload.wikimedia.org/wikipedia/commons/d/d4/King_Cobra.jpg", - "save_image": true - }' -``` - ---- - -### 4. Health Check -Check service status and model availability. - -- **Method:** `GET` -- **Path:** `/health` - -**Response:** -```json -{ - "status": "ok", - "model_loaded": true, - "model_version": "snake-yolo12-v1.0", - "uptime_s": 12345 -} -``` diff --git a/docs/02-layers/aspnet identity/aspi ui/aspi-ui.plan.md b/docs/02-layers/aspnet identity/aspi ui/aspi-ui.plan.md deleted file mode 100644 index 9820ed5b..00000000 --- a/docs/02-layers/aspnet identity/aspi ui/aspi-ui.plan.md +++ /dev/null @@ -1,242 +0,0 @@ -# ASP.NET Core Identity UI - Implementation Plan - -**Last Updated**: 2026-01-24 -**Status**: Planned for Future Implementation -**Priority**: Medium - ---- - -## Overview - -Implement Microsoft.AspNetCore.Identity.UI to provide built-in web-based admin interface for user and role management, eliminating the need for custom frontend development for administrative tasks. - -## 🎯 Goals - -- **Quick Admin Access**: Immediate web UI for managing users, roles, and permissions -- **No Frontend Required**: Built-in Razor Pages interface -- **Secure Admin Panel**: Protected routes with role-based access -- **Development Tool**: Primarily for development and admin operations - -## 📋 Implementation Strategy - -### Phase 1: Core Setup (Development Only) -- Add Identity.UI package to API project -- Configure conditional setup (development environment only) -- Basic UI access for testing - -### Phase 2: Security & Access Control -- Implement role-based access for admin UI -- Configure authentication requirements -- Add audit logging for admin actions - -### Phase 3: Customization & Integration -- Customize UI styling to match project theme -- Integrate with existing user management APIs -- Add custom admin pages if needed - -### Phase 4: Production Considerations -- Evaluate separate admin project architecture -- Implement proper security measures -- Performance optimization - -## 🏗️ Technical Architecture - -### Current Setup -- API-only project with JWT authentication -- ASP.NET Core Identity with EF Core -- PostgreSQL database with custom user entities - -### Proposed Changes -``` -SnakeAid.Api (Modified) -├── Controllers/ # Existing API controllers -├── Areas/ -│ └── Identity/ # Identity.UI Razor Pages -├── wwwroot/ # Static files for UI -└── Program.cs # Updated DI & routing -``` - -### Dependencies to Add -```xml - -``` - -## 🔧 Configuration Details - -### Program.cs Changes -```csharp -// Add to DI -builder.Services.AddIdentityCore(options => ...) - .AddRoles>() - .AddEntityFrameworkStores() - .AddDefaultTokenProviders() - .AddDefaultUI(); // Enable UI - -// Add MVC support -builder.Services.AddControllersWithViews(); -builder.Services.AddRazorPages(); - -// Routing -app.MapRazorPages(); // Identity UI routes -app.MapControllers(); // API routes -``` - -### Environment-Based Setup -```csharp -if (app.Environment.IsDevelopment()) -{ - // Enable Identity UI only in development - builder.Services.AddDefaultIdentity() - .AddDefaultUI(); -} -``` - -## 🛡️ Security Considerations - -### Access Control -- Admin UI accessible only to users with "Admin" role -- JWT authentication required for sensitive operations -- IP restrictions for admin routes (optional) - -### Route Protection -```csharp -app.MapRazorPages() - .RequireAuthorization(policy => policy.RequireRole("Admin")); -``` - -### Audit Logging -- Log all admin actions (user creation, role changes, etc.) -- Track admin user activities -- Implement change history - -## 🎨 UI Features - -### Built-in Pages -- **User Management**: `/Identity/Account/Manage/Users` - - List all users - - Create new users - - Edit user profiles - - Delete users - -- **Role Management**: `/Identity/Account/Manage/Roles` - - List all roles - - Create new roles - - Assign roles to users - - Remove roles - -- **Account Management**: `/Identity/Account/Manage` - - Change password - - Update profile - - Two-factor authentication - -### Customization Options -- Bootstrap 5 styling -- Custom CSS overrides -- Logo and branding -- Custom pages for specific business logic - -## 🔄 Migration Path - -### From Current State -1. API-only with custom auth endpoints -2. Manual role seeding -3. No admin UI - -### To Target State -1. API + Admin UI hybrid -2. Identity.UI role management -3. Web-based admin panel - -### Rollback Plan -- Conditional setup allows easy disable -- Can remove Identity.UI without affecting core auth -- API endpoints remain unchanged - -## ⚠️ Risks & Mitigations - -### Architecture Conflicts -- **Risk**: API controllers conflict with Razor Pages -- **Mitigation**: Use area routing, test thoroughly - -### Security Exposure -- **Risk**: Admin UI exposes sensitive operations -- **Mitigation**: Role-based access, audit logging, IP restrictions - -### Performance Impact -- **Risk**: Additional MVC overhead -- **Mitigation**: Development-only setup, lazy loading - -### Maintenance Complexity -- **Risk**: Mixed API + UI concerns -- **Mitigation**: Clear separation, documentation - -## 📊 Benefits vs Costs - -### Benefits -- ✅ Immediate admin functionality -- ✅ No frontend development required -- ✅ Built-in security features -- ✅ Faster development iteration - -### Costs -- ❌ Architecture complexity -- ❌ Additional dependencies -- ❌ Potential security surface -- ❌ Mixed technology stack - -## 🚀 Implementation Timeline - -### Phase 1 (1-2 days) -- [ ] Add Identity.UI package -- [ ] Configure basic setup -- [ ] Test UI access -- [ ] Document setup process - -### Phase 2 (2-3 days) -- [ ] Implement security measures -- [ ] Add role-based access -- [ ] Configure audit logging -- [ ] Test admin operations - -### Phase 3 (3-5 days) -- [ ] UI customization -- [ ] Integration testing -- [ ] Performance optimization -- [ ] Documentation update - -## 🔍 Alternatives Considered - -### 1. Separate Admin Project -**Pros**: Clean architecture, scalable -**Cons**: More complex setup, duplicate code - -### 2. Custom Admin API + SPA -**Pros**: Full control, modern UX -**Cons**: Requires frontend development - -### 3. Third-party Admin Tools -**Pros**: Feature-rich, professional -**Cons**: Cost, integration complexity - -### 4. No Admin UI (Current) -**Pros**: Simple, API-only -**Cons**: Manual database operations - -## ✅ Success Criteria - -- [ ] Admin UI accessible at `/Identity` routes -- [ ] Role-based access working -- [ ] User/role CRUD operations functional -- [ ] No conflicts with existing API -- [ ] Security audit passed -- [ ] Performance acceptable - -## 📚 References - -- [ASP.NET Core Identity UI Documentation](https://docs.microsoft.com/en-us/aspnet/core/security/authentication/identity-ui) -- [Customizing Identity UI](https://docs.microsoft.com/en-us/aspnet/core/security/authentication/customize-identity-ui) -- [Identity UI Source Code](https://github.com/dotnet/aspnetcore/tree/main/src/Identity/UI) - ---- - -**Next Steps**: Review and approve implementation plan before proceeding with Phase 1. \ No newline at end of file diff --git a/docs/02-layers/aspnet identity/aspnet-identity.introduction.md b/docs/02-layers/aspnet identity/aspnet-identity.introduction.md deleted file mode 100644 index e5be277b..00000000 --- a/docs/02-layers/aspnet identity/aspnet-identity.introduction.md +++ /dev/null @@ -1,43 +0,0 @@ -# ASP.NET Core Identity - Introduction - -> [!NOTE] -> **Goals** -> - Use ASP.NET Core Identity as the internal authentication/authorization system for SnakeAid -> - Remove Auth0 and focus on Identity + EF Core + PostgreSQL (Supabase) - -## Overview -ASP.NET Core Identity provides user/role/claim models, password management, sign-in, lockout, email confirmation, password reset, and external login (Google, etc.). -- Data is stored in the database via EF Core and migrations. -- .NET 8 provides built-in Identity API endpoints, but we will use custom JWT endpoints. - -## Database Schema (generated via migrations) -Default Identity tables: -- AspNetUsers -- AspNetRoles -- AspNetUserRoles -- AspNetUserClaims -- AspNetRoleClaims -- AspNetUserLogins -- AspNetUserTokens - -> [!TIP] -> You can extend the `Account` model to add custom fields like `FullName`, `AvatarUrl`, `IsActive`, etc. - -## Swagger & Auth Endpoints -Identity does not auto-generate Swagger endpoints unless you map them. -When you call AddIdentityApiEndpoints + MapIdentityApi(), the default auth endpoints appear in Swagger. - -## External Sign-In -Identity supports external sign-in via OAuth/OIDC providers (Google, Microsoft, Facebook, etc.). -You must configure provider client ID/secret to enable them. - -## Project Fit Notes -- Use custom auth endpoints (register/login/refresh) backed by ASP.NET Identity under `/api/auth`. -- Use JWT access + refresh tokens issued by the API. -- Clients authenticate via `Authorization: Bearer `. - -## Integration Notes -- DB: PostgreSQL (Npgsql) already exists in the project. - -> [!WARNING] -> Current auth middleware is JWT + Auth0; it **must be refactored** to use ASP.NET Core Identity instead. diff --git a/docs/02-layers/aspnet identity/aspnet-identity.plan.md b/docs/02-layers/aspnet identity/aspnet-identity.plan.md deleted file mode 100644 index 5f282702..00000000 --- a/docs/02-layers/aspnet identity/aspnet-identity.plan.md +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/docs/02-layers/aspnet identity/aspnet-identity.prompt.md b/docs/02-layers/aspnet identity/aspnet-identity.prompt.md deleted file mode 100644 index 5f282702..00000000 --- a/docs/02-layers/aspnet identity/aspnet-identity.prompt.md +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/docs/02-layers/aspnet identity/aspnet-identity.sourcecode.md b/docs/02-layers/aspnet identity/aspnet-identity.sourcecode.md deleted file mode 100644 index 84a923b4..00000000 --- a/docs/02-layers/aspnet identity/aspnet-identity.sourcecode.md +++ /dev/null @@ -1,231 +0,0 @@ -# ASP.NET Identity - Source Code Documentation - -## Overview -SnakeAid uses ASP.NET Core Identity for authentication and user management, integrated with JWT Bearer tokens for API security. The implementation uses a custom `Account` entity and `Guid` keys. - ---- - -## Configuration - -### Dependency Injection -**Location**: `SnakeAid.Api/DI/DependencyInjection.cs` - -The `AddAuthenticateAuthor` extension method configures: -1. **Identity Core**: Adds `Account` user type, SignInManager, and EntityFramework stores. -2. **Identity Options**: Configures Password, Lockout, and SignIn settings from `appsettings.json`. -3. **JWT Authentication**: Configures `JwtBearer` authentication with validation parameters. - -```csharp -public static IServiceCollection AddAuthenticateAuthor(this IServiceCollection services, IConfiguration configuration) -{ - // ... Settings binding ... - - // Add Identity Core services (without roles management) - services.AddIdentityCore(options => - { - // Password, Lockout, SignIn settings... - }) - .AddSignInManager() - .AddEntityFrameworkStores() - .AddDefaultTokenProviders(); - - // ... JWT Configuration ... -} -``` - -### Database Context -**Location**: `SnakeAid.Repository/Data/SnakeAidDbContext.cs` - -Inherits from `IdentityDbContext` with Custom User (`Account`) and Key (`Guid`). -Identity tables are mapped to the `AspNetIdentity` schema. - -```csharp -public class SnakeAidDbContext : IdentityDbContext, Guid> -{ - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - base.OnModelCreating(modelBuilder); - - // Identity tables configuration - modelBuilder.Entity().ToTable("Accounts", "AspNetIdentity"); - modelBuilder.Entity>().ToTable("UserClaims", "AspNetIdentity"); - modelBuilder.Entity>().ToTable("UserLogins", "AspNetIdentity"); - modelBuilder.Entity>().ToTable("UserTokens", "AspNetIdentity"); - - // ... - } -} -``` - ---- - -## Email & OTP Services - -### Email Service -**Interface**: `SnakeAid.Service/Interfaces/IEmailService.cs` -**Implementation**: `SnakeAid.Service/Implements/Email/Providers/ResendEmailSender.cs` - -Uses **Resend** to send emails. Configuration in `appsettings.json`: -```json -"EmailSettings": { - "DefaultFrom": "onboarding@resend.dev", - "Resend": { - "ApiKey": "re_...", - "Endpoint": "https://api.resend.com" - } -} -``` - -### OTP Service -**Interface**: `SnakeAid.Service/Interfaces/IOtpService.cs` -**Implementation**: `SnakeAid.Service/Implements/OtpService.cs` - -Manages OTP lifecycles using `IMemoryCache`. -- **Expiry**: 5 minutes. -- **Max Attempts**: 3 failed attempts before invalidation. - ---- - -## Domain Models - -### Account -**Location**: `SnakeAid.Core/Domains/Account.cs` - -Inherits `IdentityUser`. - -```csharp -public class Account : IdentityUser -{ - [Required] [MaxLength(200)] public string FullName { get; set; } - [MaxLength(1000)] public string? AvatarUrl { get; set; } - [Required] public AccountRole Role { get; set; } = AccountRole.User; - - // Status & Audit - public DateTime CreatedAt { get; set; } - public DateTime UpdatedAt { get; set; } - public bool IsActive { get; set; } = true; - - // Reputation System - public int ReputationPoints { get; set; } = 100; - public ReputationStatus ReputationStatus { get; set; } = ReputationStatus.Good; - - // ... Navigation Properties -} - -public enum AccountRole { User=0, Admin=1, Expert=2, Rescuer=3 } -public enum ReputationStatus { Excellent=0, Good=1, Average=2, Poor=3, Suspended=4 } -``` - ---- - - -## Controllers - -### AuthController -**Location**: `SnakeAid.Api/Controllers/AuthController.cs` -**Route**: `api/auth` - -Handles authentication flows. - -| Endpoint | Method | Summary | Auth Required | -|----------|--------|---------|---------------| -| `/register` | POST | Register new user | No | -| `/login` | POST | Login with email/pass | No | -| `/refresh` | POST | Refresh access token | No | -| `/google` | POST | Login/Register with Google | No | -| `/logout` | POST | Logout user | Yes | -| `/me` | GET | Get current user info | Yes | -| `/verify-account` | POST | Verify account with OTP | No | - -### EmailController -**Location**: `SnakeAid.Api/Controllers/EmailController.cs` -**Route**: `api/email` - -Handles email-related operations. - -| Endpoint | Method | Summary | Auth Required | -|----------|--------|---------|---------------| -| `/send-otp` | POST | Send OTP to user's email | No | - - ---- - -## AuthService -**Location**: `SnakeAid.Service/Implements/AuthService.cs` - -Core logic for authentication. - -### Key Logic Flows - -#### Register (`RegisterAsync`) -1. Check if Email exists (`_userManager.FindByEmailAsync`). -2. Create `Account` entity with `IsActive = false`. -3. `_userManager.CreateAsync` with password. -4. Generate Tokens (Access + Refresh). - *Note: The user is created as inactive and must verify their account.* - -#### Verify Account (`VerifyAccountAsync`) -1. Find user by Email. -2. Validate OTP using `_otpService.ValidateOtp`. -3. If valid: - - Set `IsActive = true`. - - Update user (`_userManager.UpdateAsync`). - - Generate new Tokens for immediate login. - -#### Login (`LoginAsync`) -1. Find user by Email. -2. Check `IsActive`. -3. `_signInManager.CheckPasswordSignInAsync` (updates Lockout). -4. Generate Tokens. - -#### Refresh Token (`RefreshTokenAsync`) -1. Find user by ID from request. -2. Check `IsActive`. -3. Validate Refresh Token (matches DB stored value & expiry). -4. Rotate Tokens (Remove old, Generate new). - -#### Google Login (`GoogleLoginAsync`) -1. Validate Google ID Token using `GoogleJsonWebSignature`. -2. Check Email Verified in payload. -3. Find User by Email. - - If not found: Create new user with info from Google. -4. Link Google Login (`_userManager.AddLoginAsync`) if not linked. -5. Generate Tokens. - -#### Token Generation -- **Access Token**: JWT with claims (Id, Email, Role, Claims). Expires in `jwtSettings.AccessTokenExpirationMinutes`. -- **Refresh Token**: Random 64-byte Base64 string. Stored in `AspNetUserTokens` via `_userManager.SetAuthenticationTokenAsync`. Expires in `jwtSettings.RefreshTokenExpirationDays`. - ---- - -## Request/Response Models -**Location**: `SnakeAid.Core/Requests/Auth` and `Responses/Auth` - -```csharp -// Requests -public class RegisterRequest { Email, Password, FullName, PhoneNumber } -public class LoginRequest { Email, Password } -public class RefreshTokenRequest { UserId, RefreshToken } -public class GoogleLoginRequest { IdToken } -public class SendOtpEmailRequest { Email } -public class VerifyAccountRequest { Email, Otp } - -// Response -public class AuthResponse -{ - public string AccessToken { get; set; } - public string RefreshToken { get; set; } - public DateTime AccessTokenExpiresAt { get; set; } - public DateTime RefreshTokenExpiresAt { get; set; } - public UserInfo User { get; set; } -} - -public class VerifyAccountResponse -{ - public bool Success { get; set; } - public string Message { get; set; } - public AuthResponse AuthData { get; set; } -} - -public class UserInfo { Id, Email, FullName, AvatarUrl, Role, IsActive } -``` diff --git a/docs/02-layers/aspnet identity/aspnet-identity.usageguilde.md b/docs/02-layers/aspnet identity/aspnet-identity.usageguilde.md deleted file mode 100644 index b2cfeb78..00000000 --- a/docs/02-layers/aspnet identity/aspnet-identity.usageguilde.md +++ /dev/null @@ -1,607 +0,0 @@ -# ASP.NET Identity Auth API Usage Guide - -This document describes how frontend clients (Flutter and React) should authenticate against the SnakeAid API. - -## Base URLs -- HTTP (dev): `http://localhost:5009` -- HTTPS (dev): `https://localhost:7026` - -> [!NOTE] -> Use the appropriate base URL per environment. Always use HTTPS in production. - -## Response Envelope -All auth endpoints return `ApiResponse`. - -Success example: -```json -{ - "status_code": 200, - "message": "Login successful", - "is_success": true, - "data": { - "AccessToken": "", - "RefreshToken": "", - "AccessTokenExpiresAt": "2026-01-23T18:31:22.000Z", - "RefreshTokenExpiresAt": "2026-02-22T18:31:22.000Z", - "User": { - "Id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", - "Email": "user@example.com", - "FullName": "John Doe", - "AvatarUrl": null, - "Role": "User", - "IsActive": true - } - }, - "Error": null -} -``` - -Error example: -```json -{ - "status_code": 401, - "message": "Invalid email or password.", - "is_success": false, - "data": null, - "Error": { - "ErrorCode": "UNAUTHORIZED", - "Timestamp": "2026-01-23T18:31:22.000Z", - "ValidationErrors": null - } -} -``` - -Validation error (HTTP 422): -```json -{ - "status_code": 422, - "message": "Validation failed", - "is_success": false, - "data": null, - "Error": { - "ErrorCode": "VALIDATION_ERROR", - "Timestamp": "2026-01-23T18:31:22.000Z", - "ValidationErrors": { - "Email": ["The Email field is not a valid e-mail address."] - } - } -} -``` - -## Authorization Header -For protected APIs, send: -``` -Authorization: Bearer -``` - -## Token Response -`AuthResponse` returned by register/login/refresh/google: -- `AccessToken` (JWT) -- `RefreshToken` (random string) -- `AccessTokenExpiresAt` (UTC) -- `RefreshTokenExpiresAt` (UTC) -- `User` (UserInfo object with user details) - -## Endpoints - -### 1) Register - - - - -**Endpoint**: `POST /api/auth/register` - -**Description**: Create a new user account and receive authentication tokens. - -**Request Body**: - -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `email` | string | ✅ | Valid email address (used as login identifier) | -| `password` | string | ✅ | Min 8 characters, follows password policy | -| `fullName` | string | ❌ | User's full name | -| `phoneNumber` | string | ❌ | Phone number | - -**Response**: Returns `AuthResponse` with access and refresh tokens. - -**Notes**: -- Email is used as the login identifier -- Password policy is driven by `IdentityOptions` in `appsettings.json` -- New users are automatically assigned the `User` role - - - - -#### Request - -```http -POST /api/auth/register HTTP/1.1 -Host: localhost:5009 -Content-Type: application/json - -{ - "email": "user@example.com", - "password": "P@ssw0rd!", - "fullName": "John Doe", - "phoneNumber": "0123456789" -} -``` - -#### Response - -```json -{ - "status_code": 200, - "message": "Registration successful", - "is_success": true, - "data": { - "accessToken": "eyJhbGc...", - "refreshToken": "base64...", - "accessTokenExpiresAt": "2026-01-24T18:00:00Z", - "refreshTokenExpiresAt": "2026-02-23T17:00:00Z", - "user": { - "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", - "email": "user@example.com", - "fullName": "John Doe", - "avatarUrl": null, - "role": "User", - "isActive": true - } - } -} -``` - - - - -### 2) Send OTP - - - - -**Endpoint**: `POST /api/email/send-otp` - -**Description**: Send an OTP verification code to the user's email address. - -**Request Body**: - -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `email` | string | ✅ | Email address to receive OTP | - -**Response**: Success message indicating OTP sent. - - - - -#### Request - -```http -POST /api/email/send-otp HTTP/1.1 -Host: localhost:5009 -Content-Type: application/json - -{ - "email": "user@example.com" -} -``` - -#### Response - -```json -{ - "status_code": 200, - "message": "OTP has been sent to your email. Please check your inbox.", - "is_success": true, - "data": { - "success": true, - "message": "OTP has been sent to your email.", - "authData": null - } -} -``` - - - - -### 3) Verify Account - - - - -**Endpoint**: `POST /api/auth/verify-account` - -**Description**: Verify the OTP code and activate the user account. Returns authentication tokens upon success. - -**Request Body**: - -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `email` | string | ✅ | User's email address | -| `otp` | string | ✅ | The 6-digit OTP code received | - -**Response**: Returns `VerifyAccountResponse` containing auth tokens. - -**Error Cases**: -- `400 Bad Request`: Invalid OTP or validation error -- `404 Not Found`: User not found - - - - -#### Request - -```http -POST /api/auth/verify-account HTTP/1.1 -Host: localhost:5009 -Content-Type: application/json - -{ - "email": "user@example.com", - "otp": "123456" -} -``` - -#### Response - -```json -{ - "status_code": 200, - "message": "Account verified and activated successfully.", - "is_success": true, - "data": { - "success": true, - "message": "Account verified and activated successfully.", - "authData": { - "accessToken": "eyJhbGc...", - "refreshToken": "base64...", - "accessTokenExpiresAt": "2026-01-24T18:00:00Z", - "refreshTokenExpiresAt": "2026-02-23T17:00:00Z", - "user": { - "id": "...", - "email": "...", - "isActive": true - } - } - } -} -``` - - - - -### 4) Login - - - - -**Endpoint**: `POST /api/auth/login` - -**Description**: Authenticate an existing user and receive authentication tokens. - -**Request Body**: - -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `email` | string | ✅ | User's registered email address | -| `password` | string | ✅ | User's password | - -**Response**: Returns `AuthResponse` with access and refresh tokens. - -**Error Cases**: -- `401 Unauthorized`: Invalid email or password -- `403 Forbidden`: Account is locked or inactive - - - - -#### Request - -```http -POST /api/auth/login HTTP/1.1 -Host: localhost:5009 -Content-Type: application/json - -{ - "email": "user@example.com", - "password": "P@ssw0rd!" -} -``` - -#### Response - -```json -{ - "status_code": 200, - "message": "Login successful", - "is_success": true, - "data": { - "accessToken": "eyJhbGc...", - "refreshToken": "base64...", - "accessTokenExpiresAt": "2026-01-24T18:00:00Z", - "refreshTokenExpiresAt": "2026-02-23T17:00:00Z", - "user": { - "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", - "email": "user@example.com", - "fullName": "John Doe", - "avatarUrl": null, - "role": "User", - "isActive": true - } - } -} -``` - - - - -### 5) Refresh Tokens - - - - -**Endpoint**: `POST /api/auth/refresh` - -**Description**: Exchange a refresh token for a new access and refresh token pair. - -**Request Body**: - -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `userId` | string (GUID) | ✅ | User ID from JWT `sub` or `nameidentifier` claim | -| `refreshToken` | string | ✅ | Current valid refresh token | - -**Response**: Returns `AuthResponse` with new access and refresh tokens. - -> [!WARNING] -> **Token Rotation**: On success, new access + refresh tokens are returned. The **old refresh token becomes invalid** immediately. Make sure to update stored tokens. - -**Error Cases**: -- `401 Unauthorized`: Invalid or expired refresh token - - - - -#### Request - -```http -POST /api/auth/refresh HTTP/1.1 -Host: localhost:5009 -Content-Type: application/json - -{ - "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", - "refreshToken": "CfDJ8N..." -} -``` - -#### Response - -```json -{ - "status_code": 200, - "message": "Token refreshed successfully", - "is_success": true, - "data": { - "accessToken": "eyJhbGc...", - "refreshToken": "new_base64...", - "accessTokenExpiresAt": "2026-01-24T19:00:00Z", - "refreshTokenExpiresAt": "2026-02-23T18:00:00Z", - "user": { - "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", - "email": "user@example.com", - "fullName": "John Doe", - "avatarUrl": null, - "role": "User", - "isActive": true - } - } -} -``` - - - - -### 6) Google Sign-In - - - - -**Endpoint**: `POST /api/auth/google` - -**Description**: Authenticate or register a user using Google OAuth ID token. - -**Request Body**: - -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `idToken` | string | ✅ | Google ID token obtained from client-side Google Sign-In | - -**Response**: Returns `AuthResponse` with access and refresh tokens. - -**Notes**: -- The API validates the token against `Authentication:Google:ClientId` from `appsettings.json` -- If the user does not exist, a new account is created and assigned the `User` role -- Google email must be verified, or the API returns `401 Unauthorized` - -**Error Cases**: -- `400 Bad Request`: Google configuration missing or invalid -- `401 Unauthorized`: Invalid token or unverified email - - - - -#### Request - -```http -POST /api/auth/google HTTP/1.1 -Host: localhost:5009 -Content-Type: application/json - -{ - "idToken": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjdlM..." -} -``` - -#### Response Example - -```json -{ - "status_code": 200, - "message": "Google sign-in successful", - "is_success": true, - "data": { - "accessToken": "eyJhbGc...", - "refreshToken": "base64...", - "accessTokenExpiresAt": "2026-01-24T18:00:00Z", - "refreshTokenExpiresAt": "2026-02-23T17:00:00Z", - "user": { - "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", - "email": "user@example.com", - "fullName": "John Doe", - "avatarUrl": "https://lh3.googleusercontent.com/...", - "role": "User", - "isActive": true - } - } -} -``` - - - - -### 7) Logout - - - - -**Endpoint**: `POST /api/auth/logout` - -**Description**: Logout the current user by invalidating their refresh token. - -**Request Body**: None (requires Authorization header) - -**Response**: Success message. - -**Notes**: -- Requires valid access token in Authorization header -- Invalidates the refresh token associated with the user -- Access token remains valid until expiration - -**Error Cases**: -- `401 Unauthorized`: Invalid or missing access token -- `404 Not Found`: User not found - - - - -#### Request - -```http -POST /api/auth/logout HTTP/1.1 -Host: localhost:5009 -Authorization: Bearer eyJhbGc... -``` - -#### Response - -```json -{ - "status_code": 200, - "message": "Logged out successfully", - "is_success": true, - "data": null -} -``` - - - - -### 8) Get Current User Info - - - - -**Endpoint**: `GET /api/auth/me` - -**Description**: Get information about the currently authenticated user. - -**Request Body**: None (requires Authorization header) - -**Response**: Returns `UserInfo` object. - -**Notes**: -- Requires valid access token in Authorization header -- Returns user details from JWT claims - -**Error Cases**: -- `401 Unauthorized`: Invalid or missing access token - - - - -#### Request - -```http -GET /api/auth/me HTTP/1.1 -Host: localhost:5009 -Authorization: Bearer eyJhbGc... -``` - -#### Response - -```json -{ - "status_code": 200, - "message": "User info retrieved successfully", - "is_success": true, - "data": { - "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", - "email": "user@example.com", - "fullName": "John Doe", - "avatarUrl": null, - "role": "User", - "isActive": true - } -} -``` - - - - -## JWT Claims Issued -The access token includes these claims: -- `sub` (user id) -- `email` -- `unique_name` -- `jti` -- `nameidentifier` -- `name` -- `full_name` -- `phone_number` -- `is_active` -- `role` (one or more) - -## Typical Client Flow -1) Register (tokens received, account created as Inactive). -2) Send OTP (`/api/email/send-otp`) to verify email ownership. -3) Verify Account (`/api/auth/verify-account`) to activate account and receive new tokens. -4) Store tokens securely. -5) Call protected APIs with `Authorization: Bearer `. -6) Optionally, call `/api/auth/me` to get current user information. -7) When access token expires, call `/api/auth/refresh` to rotate tokens. -8) Retry the failed request with the new access token. -9) When logging out, call `/api/auth/logout` to invalidate refresh token. - -> [!TIP] -> **Token Storage Best Practices** -> - **Mobile (Flutter)**: Use `flutter_secure_storage` to store tokens -> - **Web (React)**: Use `httpOnly` cookies or secure session storage -> - **Never** store tokens in localStorage on web (XSS vulnerability) - -## Common Error Codes -- `UNAUTHORIZED` (401) -- `FORBIDDEN` (403) -- `VALIDATION_ERROR` (422) -- `EMAIL_IN_USE` (400) -- `GOOGLE_CONFIG` (400) diff --git a/docs/02-layers/cloudinary/cloudinary.introduction.md b/docs/02-layers/cloudinary/cloudinary.introduction.md deleted file mode 100644 index 794634f3..00000000 --- a/docs/02-layers/cloudinary/cloudinary.introduction.md +++ /dev/null @@ -1,55 +0,0 @@ -# Cloudinary Integration - Introduction - -> [!NOTE] -> Goals -> - Standardize file and image storage using Cloudinary -> - Deliver a safe Cloudinary foundation that other domains can plug into later - -## Overview -Cloudinary provides managed media storage, CDN delivery, and on-the-fly transformations. In SnakeAid, it will act as the canonical storage layer for user-uploaded images and files. In this turn, the backend will upload to Cloudinary and return the normalized upload result. Domain persistence (for example `ReportMedia`, `LibraryMedia`, and `Account.AvatarUrl`) is intentionally deferred to a future phase. - -This project already references `CloudinaryDotNet` in central package management and in `SnakeAid.Service`, but the integration is not yet implemented. - -## Scope for This Turn (Cloudinary-Centric) -- Implement Cloudinary configuration, service abstraction, and generic upload endpoints. -- Avoid domain-specific persistence to reduce risk and scope creep. - -## Why Cloudinary for SnakeAid -- Reliable storage plus global CDN delivery for media-heavy flows. -- Easy generation of public HTTPS URLs (`secure_url`) suitable for AI pipelines and frontend display. -- Built-in transformations can reduce payload size while preserving recognition quality. -- Straightforward .NET SDK (`CloudinaryDotNet`) with async uploads. - -## Primary Use Cases -- Generic upload foundation: clients can upload images and receive a stable public URL. -- Future domain integrations: identification media, library media, avatars, certificates, and attachments can plug into the foundation later. - -## Integration Approach (Chosen) -SnakeAid will use server-side uploads via the backend API: -1. Client sends `multipart/form-data` to SnakeAid API. -2. API validates file type and size. -3. API uploads to Cloudinary using signed credentials. -4. API returns a normalized upload result. Persistence is a future plan. - -This mirrors the structure used in EZYFIX: -- `EzyFix.Service/Services/Implements/CloudinaryService.cs` -- `EzyFix.Service/Services/Interfaces/ICloudinaryService.cs` -- `EzyFix.API/Program.cs` Scrutor scanning -- `EzyFix.API/appsettings.example.json` Cloudinary section - -## Folder and Tagging Conventions -To keep media organized and make cleanup easier, use a consistent folder structure: -- Folder pattern: `snakeaid/{environment}/{domain}/{userId}` -- Example domains: `uploads`, `library-media`, `avatars`, `certificates`, `chat`, `files` -- Domain-specific folders such as `report-media` can be introduced in a later phase. - -Recommended tags: -- `snakeaid` -- `{environment}` -- `{domain}` -- `{referenceType}` when applicable - -## Key Design Notes for SnakeAid -- Identity user ID is available via `ClaimTypes.NameIdentifier` (not `UserId`). -- Existing file validation can be reused via `SnakeAid.Core/Validators/ValidateFileAttribute.cs`. -- Existing exception middleware maps `ApiException` types to consistent responses, so Cloudinary integration should prefer `BadRequestException`, `ValidationException`, and `UnauthorizedException` over raw exceptions. diff --git a/docs/02-layers/cloudinary/cloudinary.plan.md b/docs/02-layers/cloudinary/cloudinary.plan.md deleted file mode 100644 index 4b4f52ec..00000000 --- a/docs/02-layers/cloudinary/cloudinary.plan.md +++ /dev/null @@ -1,167 +0,0 @@ -# Cloudinary Integration - Implementation Plan - -## Scope for This Turn (Cloudinary-Centric) -- Focus on Cloudinary foundation only: configuration, service abstraction, and upload endpoints. -- Do not implement database persistence such as `ReportMedia` in this turn. -- Keep the design compatible with future persistence, but defer it to a later phase. - -## Current State (SnakeAid) -- `CloudinaryDotNet` is already referenced in `Directory.Packages.props` and `SnakeAid.Service/SnakeAid.Service.csproj`. -- There is no Cloudinary configuration section in `SnakeAid.Api/appsettings.Example.json` or `SnakeAid.Api/appsettings.json`. -- There is no `ICloudinaryService` or `CloudinaryService` implementation yet. -- There are no media upload controllers yet. -- Media URLs are already modeled in the domain layer: - - `SnakeAid.Core/Domains/ReportMedia.cs` - - `SnakeAid.Core/Domains/LibraryMedia.cs` - - `SnakeAid.Core/Domains/Account.cs` (`AvatarUrl`) - - `SnakeAid.Core/Domains/ExpertCertificate.cs` (`CertificateUrl`) - - `SnakeAid.Core/Domains/ChatMessage.cs` (`AttachmentUrl`) - - `SnakeAid.Core/Domains/FilterOption.cs` (`OptionImageUrl`) -- Scrutor scanning in `SnakeAid.Api/Program.cs` will automatically register classes ending with `Service` as their implemented interfaces. -- The authenticated user ID is available through `ClaimTypes.NameIdentifier` (see `SnakeAid.Api/Controllers/BaseController.cs`). -- File validation can be reused via `SnakeAid.Core/Validators/ValidateFileAttribute.cs`. -- Exceptions should preferably use `ApiException` types so the global middleware can produce consistent responses (see `SnakeAid.Core/Exceptions/ApiException.cs` and `SnakeAid.Core/Middlewares/ApiExceptionHandlerMiddleware.cs`). - -## Reference Pattern (EZYFIX) -EZYFIX uses a simple but effective integration: -- `ICloudinaryService` interface. -- `CloudinaryService` constructs a `Cloudinary` client from `IConfiguration` keys under `Cloudinary:*`. -- Upload methods validate file extension and size, then return `SecureUrl`. -- Scrutor scanning registers the service via the `Program.cs` scan. - -Relevant reference files: -- `D:\SourceCode\EZYFIX\EzyFix.aspnet\EzyFix.Service\Services\Interfaces\ICloudinaryService.cs` -- `D:\SourceCode\EZYFIX\EzyFix.aspnet\EzyFix.Service\Services\Implements\CloudinaryService.cs` -- `D:\SourceCode\EZYFIX\EzyFix.aspnet\EzyFix.API\Program.cs` -- `D:\SourceCode\EZYFIX\EzyFix.aspnet\EzyFix.API\appsettings.example.json` - -## Proposed Design for SnakeAid - -### 1) Configuration and Options Binding -Add a Cloudinary configuration section and a strongly-typed settings class. - -Configuration shape: -```json -"Cloudinary": { - "CloudName": "", - "ApiKey": "", - "ApiSecret": "", - "BaseFolder": "snakeaid" -} -``` - -Notes: -- Environment variables follow the standard pattern: `Cloudinary__CloudName`, `Cloudinary__ApiKey`, `Cloudinary__ApiSecret`, `Cloudinary__BaseFolder`. -- Use `BaseFolder` to centralize naming and ensure folder consistency across environments. - -### 2) Cloudinary Service Abstraction -Create a focused service that: -- Validates configuration at construction time. -- Validates files (extension and size). -- Derives user ID from `ClaimTypes.NameIdentifier` when needed. -- Uploads with safe defaults for AI and mobile clients. -- Returns a rich result (URL plus useful metadata). - -Recommended service contract: -- `UploadImageAsync(IFormFile file, ClaimsPrincipal user, string domain, CancellationToken ct = default)` -- `UploadFileAsync(IFormFile file, ClaimsPrincipal user, string domain, CancellationToken ct = default)` - -Recommended upload result model: -- `SecureUrl` -- `PublicId` -- `ResourceType` -- `Format` -- `Bytes` -- `Width` -- `Height` -- `Tags` - -### 3) API Endpoints for Uploads -Add a dedicated controller to centralize uploads. - -Recommended routes: -- `POST /api/media/upload-image` -- `POST /api/media/upload-file` - -Design choices: -- Require authentication via `[Authorize]`. -- Use `[Consumes("multipart/form-data")]`. -- Reuse `ValidateFileAttribute` for baseline validation. -- Return `ApiResponse` using `ApiResponseBuilder`. - -### 4) Future Plan - Persistence Integration Points (Deferred) -Persistence is intentionally deferred to keep this iteration Cloudinary-centric and low-risk. - -Future persistence targets: -- `ReportMedia` -- `LibraryMedia` -- `Account.AvatarUrl` -- `ExpertCertificate.CertificateUrl` -- `ChatMessage.AttachmentUrl` -- `FilterOption.OptionImageUrl` - -## Implementation Phases - -### Phase A - Cloudinary Foundation (Current Scope) -- Add `Cloudinary` config to appsettings example and environment docs. -- Add `CloudinarySettings`. -- Implement `ICloudinaryService` and `CloudinaryService`. -- Add `MediaController` upload endpoints. - -### Phase B - ReportMedia Flow (Future Plan Only) -- Add a `ReportMediaService` (or similar) that uploads via `ICloudinaryService`. -- Persist a `ReportMedia` record via `IUnitOfWork`. -- Add an endpoint such as `POST /api/report-media/upload` that both uploads and persists. - -### Phase C - Additional Domains (Future Plan Only) -- Apply the same upload pattern to avatars, library media, certificates, and chat attachments. - -## Files to Create -- `SnakeAid.Core/Settings/CloudinarySettings.cs` (or extend `SnakeAid.Core/Settings/AppSettings.cs`) -- `SnakeAid.Core/Requests/Media/UploadImageRequest.cs` -- `SnakeAid.Core/Requests/Media/UploadFileRequest.cs` -- `SnakeAid.Core/Responses/Media/CloudinaryUploadResult.cs` -- `SnakeAid.Service/Interfaces/ICloudinaryService.cs` -- `SnakeAid.Service/Implements/CloudinaryService.cs` -- `SnakeAid.Api/Controllers/MediaController.cs` - -## Future Plan Files (Do Not Implement in This Turn) -- `SnakeAid.Core/Requests/Media/UploadReportMediaRequest.cs` -- `SnakeAid.Service/Interfaces/IReportMediaService.cs` -- `SnakeAid.Service/Implements/ReportMediaService.cs` -- `SnakeAid.Api/Controllers/ReportMediaController.cs` - -## Files to Modify -- `SnakeAid.Api/appsettings.Example.json` (add `Cloudinary` section) -- `SnakeAid.Api/appsettings.json` (local dev values only, or leave empty and use env vars) -- `SnakeAid.Api/DI/DependencyInjection.cs` - -Recommended DI changes: -- Bind options: `services.Configure(configuration.GetSection("Cloudinary"));` -- Validate settings at startup and fail fast if missing. - -## Cloudinary Upload Defaults (Recommended) -For snake recognition images, avoid destructive cropping and keep aspect ratio. - -Recommended transformation for images: -- Width limit: 1600 -- Crop mode: `limit` -- Quality: `auto` -- Format: `auto` - -This reduces payload size while preserving details for AI. - -## Risks and Mitigations -- Missing credentials causes runtime errors. - - Mitigation: validate config at startup and throw a clear `InvalidOperationException` or `BadRequestException`. -- URL length constraints may be tight for some transformed URLs. - - Mitigation: consider raising URL length limits to 2000 where possible. -- Current exception middleware maps only `ApiException` and `InvalidOperationException` explicitly. - - Mitigation: use `BadRequestException`, `ValidationException`, or `InvalidOperationException` instead of raw `ArgumentException`. -- Deleting Cloudinary assets later will be harder if only the URL is stored. - - Mitigation: include and persist `PublicId` in a follow-up migration if deletion is needed. - -## Success Criteria -- Upload endpoints return a valid `secure_url`. -- No database schema changes are required in this phase. -- Folder and tagging conventions are consistent and ready for future persistence. diff --git a/docs/02-layers/cloudinary/cloudinary.prompt.md b/docs/02-layers/cloudinary/cloudinary.prompt.md deleted file mode 100644 index c94d66da..00000000 --- a/docs/02-layers/cloudinary/cloudinary.prompt.md +++ /dev/null @@ -1,240 +0,0 @@ -# Cloudinary Integration - Agent Prompt - -Use this prompt to implement Cloudinary uploads in SnakeAid, mirroring the working pattern from EZYFIX but aligning with SnakeAid's current architecture, claims, and response conventions. - -## Goals -- Implement a production-ready `ICloudinaryService` + `CloudinaryService`. -- Add authenticated upload endpoints under `/api/media`. -- Return consistent `ApiResponse` results. -- Keep the first iteration minimal: no database migrations required. - -## Scope Guard for This Turn -- Implement Cloudinary foundation only. -- Do not implement `ReportMedia` persistence or any upload-and-persist endpoints in this turn. -- Keep the result generic and reusable across domains. - -## Important SnakeAid Context -- Scrutor scanning in `SnakeAid.Api/Program.cs` auto-registers classes ending with `Service` as implemented interfaces. -- User ID is available via `ClaimTypes.NameIdentifier` (see `SnakeAid.Api/Controllers/BaseController.cs`). -- Use `ApiException` types for consistent error mapping (see `SnakeAid.Core/Exceptions/ApiException.cs`). -- There is already a reusable file validator: `SnakeAid.Core/Validators/ValidateFileAttribute.cs`. - -## Step 1 - Add Cloudinary Settings -Create a strongly-typed settings class. - -Recommended location: -- New file: `SnakeAid.Core/Settings/CloudinarySettings.cs` - -Recommended content: -```csharp -namespace SnakeAid.Core.Settings; - -public class CloudinarySettings -{ - public string CloudName { get; set; } = string.Empty; - public string ApiKey { get; set; } = string.Empty; - public string ApiSecret { get; set; } = string.Empty; - public string BaseFolder { get; set; } = "snakeaid"; -} -``` - -## Step 2 - Bind and Validate Settings in DI -Update `SnakeAid.Api/DI/DependencyInjection.cs` inside `AddServices`. - -Add: -```csharp -services.Configure(configuration.GetSection("Cloudinary")); - -var cloudinarySettings = configuration.GetSection("Cloudinary").Get(); -if (cloudinarySettings is null || - string.IsNullOrWhiteSpace(cloudinarySettings.CloudName) || - string.IsNullOrWhiteSpace(cloudinarySettings.ApiKey) || - string.IsNullOrWhiteSpace(cloudinarySettings.ApiSecret)) -{ - throw new InvalidOperationException("Cloudinary settings are not configured properly."); -} -``` - -Also add the required `using`: -```csharp -using SnakeAid.Core.Settings; -``` - -## Step 3 - Add Upload Contracts -Create simple request and response models. - -Suggested files: -- `SnakeAid.Core/Requests/Media/UploadImageRequest.cs` -- `SnakeAid.Core/Requests/Media/UploadFileRequest.cs` -- `SnakeAid.Core/Responses/Media/CloudinaryUploadResult.cs` - -Suggested response model: -```csharp -namespace SnakeAid.Core.Responses.Media; - -public class CloudinaryUploadResult -{ - public string SecureUrl { get; set; } = string.Empty; - public string PublicId { get; set; } = string.Empty; - public string ResourceType { get; set; } = string.Empty; - public string? Format { get; set; } - public long? Bytes { get; set; } - public int? Width { get; set; } - public int? Height { get; set; } - public string Folder { get; set; } = string.Empty; - public IReadOnlyCollection Tags { get; set; } = Array.Empty(); -} -``` - -Suggested image request: -```csharp -namespace SnakeAid.Core.Requests.Media; - -public class UploadImageRequest -{ - public IFormFile File { get; set; } = default!; - public string Domain { get; set; } = "uploads"; -} -``` - -Suggested file request: -```csharp -namespace SnakeAid.Core.Requests.Media; - -public class UploadFileRequest -{ - public IFormFile File { get; set; } = default!; - public string Domain { get; set; } = "files"; -} -``` - -## Step 4 - Add ICloudinaryService -Create: -- `SnakeAid.Service/Interfaces/ICloudinaryService.cs` - -Suggested contract: -```csharp -using System.Security.Claims; -using SnakeAid.Core.Responses.Media; - -namespace SnakeAid.Service.Interfaces; - -public interface ICloudinaryService -{ - Task UploadImageAsync( - IFormFile file, - ClaimsPrincipal user, - string domain, - CancellationToken cancellationToken = default); - - Task UploadFileAsync( - IFormFile file, - ClaimsPrincipal user, - string domain, - CancellationToken cancellationToken = default); -} -``` - -## Step 5 - Implement CloudinaryService -Create: -- `SnakeAid.Service/Implements/CloudinaryService.cs` - -Implementation requirements: -- Use `CloudinaryDotNet` and `CloudinaryDotNet.Actions`. -- Read and validate `CloudinarySettings`. -- Extract user ID from `ClaimTypes.NameIdentifier`. -- Validate extension and size limits. -- Use non-destructive transformations for snake recognition images. -- Prefer `BadRequestException`, `ValidationException`, and `UnauthorizedException` instead of raw exceptions. - -Suggested defaults: -- Allowed image extensions: `.jpg`, `.jpeg`, `.png`, `.webp` -- Max image size: 10 MB -- Max file size: 100 MB - -Suggested image transformation: -```csharp -new Transformation() - .Width(1600) - .Crop("limit") - .Quality("auto") - .FetchFormat("auto"); -``` - -Folder convention: -- `snakeaid/{environment}/{domain}/{userId}` -- Environment can be derived from `IHostEnvironment.EnvironmentName`. - -## Step 6 - Add MediaController -Create: -- `SnakeAid.Api/Controllers/MediaController.cs` - -Controller requirements: -- Route: `[Route("api/media")]` -- Use `[Authorize]` -- Use `[Consumes("multipart/form-data")]` on upload actions -- Use `ValidateFileAttribute` for quick validation -- Use `ApiResponseBuilder.BuildSuccessResponse(...)` - -Suggested endpoints: -- `POST /api/media/upload-image` -- `POST /api/media/upload-file` - -Suggested structure: -```csharp -[ApiController] -[Authorize] -[Route("api/media")] -public class MediaController : BaseController -{ - private readonly ICloudinaryService _cloudinaryService; - - public MediaController( - ILogger logger, - IHttpContextAccessor httpContextAccessor, - IMapper mapper, - ICloudinaryService cloudinaryService) - : base(logger, httpContextAccessor, mapper) - { - _cloudinaryService = cloudinaryService; - } - - [HttpPost("upload-image")] - [Consumes("multipart/form-data")] - [ValidateFile(maxSizeInMB: 10, formFieldName: "file")] - public async Task UploadImage([FromForm] UploadImageRequest request, CancellationToken ct) - { - var result = await _cloudinaryService.UploadImageAsync(request.File, User, request.Domain, ct); - return Ok(ApiResponseBuilder.BuildSuccessResponse(result, "Image uploaded successfully.")); - } -} -``` - -## Step 7 - Add Cloudinary Config to AppSettings Example -Update: -- `SnakeAid.Api/appsettings.Example.json` - -Add: -```json -"Cloudinary": { - "CloudName": "YOUR_CLOUD_NAME", - "ApiKey": "YOUR_API_KEY", - "ApiSecret": "YOUR_API_SECRET", - "BaseFolder": "snakeaid" -} -``` - -## Step 8 - Manual Validation Checklist -After implementing: -1. Run the API locally. -2. Authenticate via `/api/auth/login`. -3. Use Swagger to call `/api/media/upload-image`. -4. Confirm a valid `secure_url` is returned. -5. Open the returned URL in a browser. - -## Future Plan - ReportMedia Persistence (Do Not Implement in This Turn) -Add an upload-and-persist endpoint for `ReportMedia` later, once the Cloudinary foundation is stable. - -Minimal future shape: -- Request includes `ReferenceId`, `ReferenceType`, `Purpose`, and `File`. -- Service uploads to Cloudinary and inserts a `ReportMedia` record via `IUnitOfWork`. diff --git a/docs/02-layers/cloudinary/cloudinary.sourcecode.md b/docs/02-layers/cloudinary/cloudinary.sourcecode.md deleted file mode 100644 index 0b993814..00000000 --- a/docs/02-layers/cloudinary/cloudinary.sourcecode.md +++ /dev/null @@ -1,246 +0,0 @@ -# Cloudinary Integration - Source Code Documentation - -This document describes the intended source code structure after Cloudinary integration is implemented in SnakeAid. It is written to minimize future code crawling by capturing the key method signatures, flows, and configuration contracts in one place. - -## Status -As of 2026-01-27, the Cloudinary foundation (Phase A) is implemented: -- Settings: `SnakeAid.Core/Settings/AppSettings.cs` (contains `CloudinarySettings` class) -- Contracts: `SnakeAid.Core/Requests/Media/UploadImageRequest.cs` -- Contracts: `SnakeAid.Core/Requests/Media/UploadFileRequest.cs` -- Contracts: `SnakeAid.Core/Responses/Media/CloudinaryUploadResult.cs` -- Service: `SnakeAid.Service/Interfaces/ICloudinaryService.cs` -- Service: `SnakeAid.Service/Implements/CloudinaryService.cs` -- API: `SnakeAid.Api/Controllers/MediaController.cs` -- API: `SnakeAid.Api/DI/DependencyInjection.cs` -- API: `SnakeAid.Api/Program.cs` -- API: `SnakeAid.Api/appsettings.Example.json` - -## Scope for This Turn (Cloudinary-Centric) -- Document the Cloudinary foundation only. -- Do not include database persistence such as `ReportMedia` in this turn. -- Keep contracts and folder conventions ready for future persistence phases. - -## Configuration - -### Cloudinary Settings Class -**Location**: `SnakeAid.Core/Settings/AppSettings.cs` (CloudinarySettings class) - -```csharp -namespace SnakeAid.Core.Settings; - -public class CloudinarySettings -{ - public string CloudName { get; set; } = string.Empty; - public string ApiKey { get; set; } = string.Empty; - public string ApiSecret { get; set; } = string.Empty; - public string BaseFolder { get; set; } = "snakeaid"; -} -``` - -### DI Binding and Validation -**Location**: `SnakeAid.Api/DI/DependencyInjection.cs` - -`AddServices` now accepts `IConfiguration`: - -```csharp -public static IServiceCollection AddServices(this IServiceCollection services, IConfiguration configuration) -``` - -Inside `AddServices(IServiceCollection services, IConfiguration configuration)`: - -```csharp -var cloudinarySection = configuration.GetSection("Cloudinary"); -services.Configure(cloudinarySection); - -if (cloudinarySection.Exists()) -{ - var cloudinarySettings = cloudinarySection.Get(); - if (cloudinarySettings is null || - string.IsNullOrWhiteSpace(cloudinarySettings.CloudName) || - string.IsNullOrWhiteSpace(cloudinarySettings.ApiKey) || - string.IsNullOrWhiteSpace(cloudinarySettings.ApiSecret)) - { - throw new InvalidOperationException("Cloudinary settings are not configured properly."); - } -} -``` - -`Program.cs` now passes configuration into DI: - -```csharp -builder.Services.AddServices(builder.Configuration); -``` - -## Service Layer - -### ICloudinaryService -**Location**: `SnakeAid.Service/Interfaces/ICloudinaryService.cs` - -```csharp -using System.Security.Claims; -using SnakeAid.Core.Responses.Media; - -namespace SnakeAid.Service.Interfaces; - -public interface ICloudinaryService -{ - Task UploadImageAsync( - IFormFile file, - ClaimsPrincipal user, - string domain, - CancellationToken cancellationToken = default); - - Task UploadFileAsync( - IFormFile file, - ClaimsPrincipal user, - string domain, - CancellationToken cancellationToken = default); -} -``` - -### CloudinaryUploadResult -**Location**: `SnakeAid.Core/Responses/Media/CloudinaryUploadResult.cs` - -```csharp -namespace SnakeAid.Core.Responses.Media; - -public class CloudinaryUploadResult -{ - public string SecureUrl { get; set; } = string.Empty; - public string PublicId { get; set; } = string.Empty; - public string ResourceType { get; set; } = string.Empty; - public string? Format { get; set; } - public long? Bytes { get; set; } - public int? Width { get; set; } - public int? Height { get; set; } - public string Folder { get; set; } = string.Empty; - public IReadOnlyCollection Tags { get; set; } = Array.Empty(); -} -``` - -### CloudinaryService -**Location**: `SnakeAid.Service/Implements/CloudinaryService.cs` - -#### Constructor Flow -1. Load `CloudinarySettings`. -2. Validate required fields. -3. Create the Cloudinary `Account` and `Cloudinary` client. -4. Cache allowed extensions and size limits. - -Constructor signature: -```csharp -public CloudinaryService( - IOptions options, - IHostEnvironment env, - ILogger logger) -``` - -#### UploadImageAsync Flow -High-level steps: -1. Validate input file. -2. Extract user ID from `ClaimTypes.NameIdentifier`. -3. Build folder: `{BaseFolder}/{EnvironmentName}/{domain}/{userId}`. -4. Build public ID from sanitized file name plus timestamp. -5. Upload using `ImageUploadParams` with non-destructive transformation. -6. Validate `SecureUrl`. -7. Map SDK result to `CloudinaryUploadResult`. - -Recommended transformation: -```csharp -new Transformation() - .Width(1600) - .Crop("limit") - .Quality("auto") - .FetchFormat("auto"); -``` - -#### UploadFileAsync Flow -High-level steps: -1. Validate input file and size. -2. Extract user ID. -3. Build folder and public ID. -4. Upload using `AutoUploadParams`. -5. Validate `SecureUrl`. -6. Map to `CloudinaryUploadResult`. - -#### Error Handling Strategy -Prefer throwing `ApiException` types: -- `ValidationException` for type and size issues. -- `UnauthorizedException` for missing or invalid user claims. -- `BadRequestException` for Cloudinary errors or missing URLs. - -This aligns with `SnakeAid.Core/Middlewares/ApiExceptionHandlerMiddleware.cs`. - -## API Layer - -### Upload Requests -**Locations**: -- `SnakeAid.Core/Requests/Media/UploadImageRequest.cs` -- `SnakeAid.Core/Requests/Media/UploadFileRequest.cs` - -Minimal request shapes: -```csharp -public class UploadImageRequest -{ - public IFormFile File { get; set; } = default!; - public string Domain { get; set; } = "uploads"; -} - -public class UploadFileRequest -{ - public IFormFile File { get; set; } = default!; - public string Domain { get; set; } = "files"; -} -``` - -### MediaController -**Location**: `SnakeAid.Api/Controllers/MediaController.cs` - -Controller signature: -```csharp -[ApiController] -[Authorize] -[Route("api/media")] -public class MediaController : BaseController -``` - -Endpoint summary: -- `POST /api/media/upload-image` -- `POST /api/media/upload-file` - -Endpoint flow: -1. Validate multipart request. -2. Call `ICloudinaryService`. -3. Return `ApiResponse` via `ApiResponseBuilder.BuildSuccessResponse`. - -## Future Plan - Persistence (Deferred) -Persistence, including `ReportMedia`, is intentionally deferred in this turn to minimize risk and keep the implementation Cloudinary-centric. - -## End-to-End Upload Flow - -```mermaid -sequenceDiagram - participant Client - participant API as SnakeAid.Api - participant Service as CloudinaryService - participant Cloudinary - - Client->>API: POST /api/media/upload-image (multipart/form-data) - API->>Service: UploadImageAsync(file, user, domain) - Service->>Cloudinary: UploadAsync(ImageUploadParams) - Cloudinary-->>Service: secure_url + public_id - Service-->>API: CloudinaryUploadResult - API-->>Client: ApiResponse -``` - -## Future Plan - Potential Domain Consumers -The following domains are expected to consume Cloudinary URLs in later phases: -- `SnakeAid.Core/Domains/ReportMedia.cs` -- `SnakeAid.Core/Domains/LibraryMedia.cs` -- `SnakeAid.Core/Domains/Account.cs` -- `SnakeAid.Core/Domains/ExpertCertificate.cs` -- `SnakeAid.Core/Domains/ChatMessage.cs` -- `SnakeAid.Core/Domains/FilterOption.cs` - -## Notes on Deletion and Cleanup -Current domain models do not store Cloudinary `PublicId`. If deletion is required later, add `PublicId` fields and a follow-up migration so the backend can delete assets via the Cloudinary API. diff --git a/docs/02-layers/cloudinary/cloudinary.usageguilde.md b/docs/02-layers/cloudinary/cloudinary.usageguilde.md deleted file mode 100644 index 136371ff..00000000 --- a/docs/02-layers/cloudinary/cloudinary.usageguilde.md +++ /dev/null @@ -1,118 +0,0 @@ -# Cloudinary Integration - Usage Guide - -This guide describes how frontend or mobile clients should upload media once the Cloudinary integration is implemented in SnakeAid. - -## Quick Summary -- Uploads go through the SnakeAid backend, not directly to Cloudinary. -- The backend returns a public `secure_url`. -- Domain persistence and AI flows are future phases; this guide focuses on uploads only. - -## Authentication -Uploads should require authentication. - -Typical flow: -1. Call `POST /api/auth/login`. -2. Read `data.accessToken` from the response. -3. Send `Authorization: Bearer ` in upload requests. - -## Endpoints - -### 1) Upload Image -- Method: `POST` -- Path: `/api/media/upload-image` -- Content-Type: `multipart/form-data` -- Auth: required - -Form fields: -- `file`: required, the image file -- `domain`: optional, default `uploads` - -Example with curl: -```bash -curl -X POST "https://localhost:7026/api/media/upload-image" \ - -H "Authorization: Bearer " \ - -F "file=@C:/images/snake.jpg" \ - -F "domain=uploads" -``` - -Example response: -```json -{ - "status_code": 200, - "message": "Image uploaded successfully.", - "is_success": true, - "data": { - "secureUrl": "https://res.cloudinary.com//image/upload/v123/snakeaid/dev/uploads//snake_20260127.jpg", - "publicId": "snakeaid/dev/uploads//snake_20260127", - "resourceType": "image", - "format": "jpg", - "bytes": 345678, - "width": 1280, - "height": 720, - "folder": "snakeaid/dev/uploads/", - "tags": ["snakeaid", "dev", "uploads"] - }, - "error": null -} -``` - -### 2) Upload File -- Method: `POST` -- Path: `/api/media/upload-file` -- Content-Type: `multipart/form-data` -- Auth: required - -Form fields: -- `file`: required -- `domain`: optional, default `files` - -## Recommended Domain Values -Use consistent domain values to keep Cloudinary organized: -- `uploads` -- `library-media` -- `avatars` -- `certificates` -- `chat` -- `files` -- `report-media` (future plan) - -## JavaScript Example -```javascript -async function uploadSnakeImage(file, accessToken) { - const form = new FormData(); - form.append("file", file); - form.append("domain", "uploads"); - - const res = await fetch("https://localhost:7026/api/media/upload-image", { - method: "POST", - headers: { - Authorization: `Bearer ${accessToken}` - }, - body: form - }); - - const body = await res.json(); - if (!body.is_success) { - throw new Error(body.message); - } - - return body.data.secureUrl; -} -``` - -## Future Plan - Identification Flow (Deferred) -Identification-specific flows (such as `ReportMedia` persistence and AI handoff) are intentionally deferred in this turn. - -## Error Handling Notes -Likely error categories: -- Validation errors for file size or extension. -- Authentication errors when the token is missing or expired. -- Server errors when Cloudinary credentials are not configured. - -Client recommendations: -- Always check `is_success` and `status_code`. -- Display `message` to the user when appropriate. -- For 401 responses, redirect to login and refresh the token if supported. - -## Future Plan - Upload and Persist -A later endpoint can both upload and create a `ReportMedia` record in one call, reducing client complexity. Prefer that endpoint once it exists. diff --git a/docs/02-layers/docker/docker.introduction.md b/docs/02-layers/docker/docker.introduction.md deleted file mode 100644 index 1027bafc..00000000 --- a/docs/02-layers/docker/docker.introduction.md +++ /dev/null @@ -1,13 +0,0 @@ -# Docker - Introduction - -## What is it? -Docker is a platform for developing, shipping, and running applications in containers. `docker-compose` is a tool for defining and running multi-container Docker applications. - -## Why use it? -- **Consistency**: Ensures the application runs the same way on every machine (local dev, Jenkins, production). -- **Simplicity**: `docker compose up` is all you need to start the application with all its dependencies (or configuration). -- **Isolation**: Keeps dependencies (like .NET runtime) inside the container, keeping your host machine clean. - -## Use Cases -- **Local Development**: Quickly start the backend without installing the exact .NET SDK version locally. -- **Testing**: Verify that the application builds and runs correctly in a Linux environment matching production. diff --git a/docs/02-layers/docker/docker.sourcecode.md b/docs/02-layers/docker/docker.sourcecode.md deleted file mode 100644 index 82f523bd..00000000 --- a/docs/02-layers/docker/docker.sourcecode.md +++ /dev/null @@ -1,25 +0,0 @@ -# Docker - Source Code - -## Docker Compose -**Location**: [docker-compose.yml](../../docker-compose.yml) - -Configuration for running the SnakeAid Backend service. - -```yaml -services: - snakeaid-backend: - build: - context: . - dockerfile: Dockerfile - ports: - - "8080:8080" - environment: - - ASPNETCORE_ENVIRONMENT=Development - - ASPNETCORE_URLS=http://+:8080 - restart: unless-stopped -``` - -## Dockerfile -**Location**: [Dockerfile](../../Dockerfile) - -(See [Jenkins Source Code](../Jenkins/jenkins.sourcecode.md) for Dockerfile content) diff --git a/docs/02-layers/docker/docker.usageguide.md b/docs/02-layers/docker/docker.usageguide.md deleted file mode 100644 index 48cdea6e..00000000 --- a/docs/02-layers/docker/docker.usageguide.md +++ /dev/null @@ -1,47 +0,0 @@ -# Docker & Docker Compose - Usage Guide - -## Prerequisites -- **Docker Desktop** (Windows/Mac) or **Docker Engine** (Linux) installed. -- **Git** (optional, to clone repo). - -## Running the Application Locally - -We use `docker-compose` to simplify building and running the application in a containerized environment. - -### 1. Start the Application -Run the following command in the root directory (where `docker-compose.yml` is located): - -```bash -docker compose up --build -``` - -- `--build`: Forces a rebuild of the image (useful if you changed code). -- `-d`: (Optional) Run in detached mode (background). - -### 2. Access the Application -Once the container is running, access the API at: -- **Swagger UI**: [http://localhost:8080/swagger](http://localhost:8080/swagger) -- **API Endpoint**: `http://localhost:8080/api/...` - -### 3. Stop the Application -To stop the containers: - -```bash -docker compose down -``` - -## Configuration - -The `docker-compose.yml` is configured with: -- **Port**: `8080` mapped to host `8080`. -- **Environment**: `Development` (enables Swagger). -- **Database**: Connects to the external Supabase instance defined in `appsettings.json`. No local DB container is spun up. - -## Troubleshooting - -- **Port Conflict**: If port 8080 is in use, modify `docker-compose.yml`: - ```yaml - ports: - - "8081:8080" # Maps host 8081 to container 8080 - ``` -- **Database Connection**: Ensure your machine has internet access to reach the Supabase instance. diff --git a/docs/02-layers/domain driven design/ddd-migration.introduction.md b/docs/02-layers/domain driven design/ddd-migration.introduction.md deleted file mode 100644 index e86c9f1d..00000000 --- a/docs/02-layers/domain driven design/ddd-migration.introduction.md +++ /dev/null @@ -1,139 +0,0 @@ -# Domain-Driven Design Migration - Introduction - -## What is Domain-Driven Design? - -Domain-Driven Design (DDD) là một phương pháp tiếp cận thiết kế phần mềm tập trung vào **business domain** và **business logic** thay vì chỉ tập trung vào technical implementation. DDD giúp xây dựng các hệ thống phức tạp bằng cách: - -- **Domain-centric**: Đặt business logic làm trung tâm -- **Bounded Context**: Chia domain thành các context nhỏ, độc lập -- **Ubiquitous Language**: Sử dụng ngôn ngữ chung giữa business và technical team -- **Rich Domain Model**: Entities chứa behavior, không chỉ data - -## Current Architecture vs DDD - -### Current N-Tier Architecture: -``` -┌─────────────────────┐ -│ SnakeAid.API │ ◄── Presentation Layer -├─────────────────────┤ -│ SnakeAid.Service │ ◄── Business Logic Layer -├─────────────────────┤ -│ SnakeAid.Core │ ◄── Domain Models (Anemic) -├─────────────────────┤ -│ SnakeAid.Repository │ ◄── Data Access Layer -└─────────────────────┘ -``` - -### Target DDD Architecture: -``` -┌─────────────────────────────────────────────────────────────────┐ -│ SnakeAid.API (Gateway) │ -├─────────────────────────────────────────────────────────────────┤ -│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ -│ │ Emergency Rescue│ │ Snake Catching │ │ Expert │ │ -│ │ Context │ │ Context │ │ Consultation │ │ -│ │ │ │ │ │ Context │ │ -│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │ -├─────────────────────────────────────────────────────────────────┤ -│ Shared Kernel │ -│ • Location Tracking • AI Recognition • Media Management │ -└─────────────────────────────────────────────────────────────────┘ -``` - -## Why Migrate to DDD? - -### Current Problems: -1. **Anemic Domain Model**: Entities chỉ chứa properties, logic nằm rải rác trong Services -2. **Cross-cutting Concerns**: Business logic phụ thuộc vào infrastructure -3. **Tight Coupling**: Changes ở 1 module ảnh hưởng toàn hệ thống -4. **Complex Business Rules**: Emergency rescue, snake catching có nhiều rule phức tạp -5. **Team Scalability**: Khó để multiple teams làm việc parallel - -### Benefits of DDD: -1. **Rich Domain Models**: Business logic tập trung trong domain entities -2. **Bounded Contexts**: Độc lập deployment và scaling -3. **Domain Events**: Loose coupling giữa các contexts -4. **Testability**: Domain logic tách biệt infrastructure -5. **Business Alignment**: Code reflect business processes - -## SnakeAid Domain Analysis - -### Core Domains (High Business Value): -1. **Emergency Rescue** - Cấp cứu khi bị rắn cắn -2. **Snake Catching** - Dịch vụ bắt rắn theo yêu cầu -3. **Expert Consultation** - Tư vấn chuyên gia - -### Supporting Domains (Important but not differentiating): -4. **Snake Knowledge** - Thông tin về rắn, nọc độc, thuốc giải -5. **Payment & Wallet** - Thanh toán, ví điện tử -6. **Identity & Profile** - User management, profiles -7. **Communication** - Chat, notifications, video call - -### Generic Domains (Commodity): -8. **Reputation & Feedback** - Rating system - -## Business Flows Identified - -### Emergency Flow: -``` -Patient reports bite → AI identifies snake → Find nearby rescuer → -Provide first aid guidance → Rescuer dispatched → Track in realtime → -Refer to hospital → Complete mission -``` - -### Snake Catching Flow: -``` -Customer requests catching → Upload photos → AI identification → -Price calculation → Find available rescuer → Accept job → -Track progress → Complete catching → Payment -``` - -### Expert Consultation Flow: -``` -Book consultation → Select expert → Upload snake photos → -AI pre-analysis → Video/chat session → Expert diagnosis → -Follow-up recommendations → Payment -``` - -## Migration Strategy Overview - -### Phase-based Approach: -- **Phase 1**: Foundation & Core Domain (Emergency Rescue) -- **Phase 2**: Snake Catching Context -- **Phase 3**: Expert Consultation Context -- **Phase 4**: Supporting Domains -- **Phase 5**: Integration & Optimization - -### Key Principles: -- **Strangler Fig Pattern**: Gradually replace old system -- **Domain Events**: Decouple contexts -- **Shared Kernel**: Common utilities (AI, Location, Media) -- **CQRS**: Separate read/write models where beneficial -- **Event Sourcing**: For audit trails (missions, consultations) - -## Expected Outcomes - -### Technical Benefits: -- ✅ Cleaner, more maintainable code -- ✅ Better separation of concerns -- ✅ Easier unit testing -- ✅ Independent deployment of contexts -- ✅ Better performance through focused queries - -### Business Benefits: -- ✅ Faster feature development -- ✅ Better business-tech alignment -- ✅ Easier onboarding of new developers -- ✅ Reduced bug rates -- ✅ Better system reliability - -## References - -- **Domain-Driven Design**: Eric Evans -- **Implementing Domain-Driven Design**: Vaughn Vernon -- **Clean Architecture**: Robert C. Martin -- **.NET DDD Examples**: https://github.com/dotnet-architecture/eShopOnContainers - ---- - -**Next**: [Migration Plan](ddd-migration.plan.md) \ No newline at end of file diff --git a/docs/02-layers/domain driven design/ddd-migration.plan.md b/docs/02-layers/domain driven design/ddd-migration.plan.md deleted file mode 100644 index b28697a6..00000000 --- a/docs/02-layers/domain driven design/ddd-migration.plan.md +++ /dev/null @@ -1,252 +0,0 @@ -# Feature-Based Architecture Migration - Implementation Plan - -## Migration Overview - -Thay vì Domain-Driven Design phức tạp, chúng ta sẽ implement **Feature-Based Architecture** (Vertical Slice) cho SnakeAid Backend - approach đơn giản, thực tế hơn cho team size và timeline hiện tại. - -## Why Feature-Based instead of DDD? - -### 🚨 DDD Challenges for SnakeAid: -- **Overkill**: Project complexity chưa đủ lớn để justify DDD overhead -- **Team Learning Curve**: DDD patterns phức tạp, slow down initial development -- **Time Pressure**: Need to ship features fast, not perfect architecture -- **Maintenance**: Simple > Perfect cho current team size - -### ✅ Feature-Based Benefits: -- **Familiar**: Team đã biết MVC pattern -- **Fast Setup**: Days not weeks to organize -- **Easy to Understand**: Junior developers can contribute immediately -- **Pragmatic**: Right level of organization without overhead - -## Target Architecture: Feature-Based MVC - -``` -SnakeAid.Backend/ -├── SnakeAid.API/ # Main API Project -│ ├── Features/ # Organized by business features -│ │ ├── SOS/ # Emergency rescue -│ │ ├── Catching/ # Snake catching service -│ │ ├── Consultation/ # Expert consultation -│ │ ├── Community/ # Community reports -│ │ └── Shared/ # Cross-feature utilities -│ ├── Program.cs -│ └── appsettings.json -├── SnakeAid.Core/ # Keep existing domain entities -└── SnakeAid.Infrastructure/ # Data access & external services -``` - -## Phase-based Approach (4 Weeks instead of 12) - -## Phase 1: Setup Feature-Based Structure (Week 1) - -### 🎯 Goals -- Tạo feature-based folder structure -- Migrate existing controllers theo features -- Setup shared utilities - -### 📋 Tasks - -#### Tạo Feature Structure: -```bash -# Tạo feature folders -mkdir -p SnakeAid.API/Features/{SOS,Catching,Consultation,Community,Shared} -mkdir -p SnakeAid.API/Features/SOS/{Controllers,Models,Services,Repositories,Mappings} -mkdir -p SnakeAid.API/Features/SOS/Models/{Requests,Responses} - -# Tương tự cho các features khác -# Catching, Consultation, Community -``` - -#### Move Existing Code: -1. **Controllers**: Move từ `SnakeAid.API/Controllers/` sang feature folders -2. **DTOs**: Break down `DomainRequestHub` + `DomainResponseHub` theo features -3. **Services**: Organize business logic theo features - -### 📁 Structure After Phase 1: -``` -SnakeAid.API/ -├── Features/ -│ ├── SOS/ -│ │ ├── Controllers/ -│ │ │ └── SOSController.cs -│ │ ├── Models/ -│ │ │ ├── Requests/ -│ │ │ │ ├── CreateSOSRequest.cs -│ │ │ │ └── UpdateSOSStatusRequest.cs -│ │ │ └── Responses/ -│ │ │ ├── SOSDetailResponse.cs -│ │ │ └── SOSDashboardResponse.cs -│ │ ├── Services/ -│ │ │ ├── ISOSService.cs -│ │ │ └── SOSService.cs -│ │ ├── Repositories/ -│ │ │ ├── ISOSRepository.cs -│ │ │ └── SOSRepository.cs -│ │ └── Mappings/ -│ │ └── SOSMappingProfile.cs -│ │ -│ └── Shared/ -│ ├── AI/ -│ │ ├── ISnakeIdentificationService.cs -│ │ └── SnakeIdentificationService.cs -│ ├── Location/ -│ │ ├── ILocationTrackingService.cs -│ │ └── LocationTrackingService.cs -│ └── Media/ -│ ├── IMediaService.cs -│ └── MediaService.cs -``` - ---- - -## Phase 2: SOS Feature Complete (Week 2) - -### 🎯 Goals -- Complete SOS (Emergency) feature implementation -- Establish patterns for other features -- Testing setup - -### 📋 Implementation - -#### SOS Service Example: -```csharp -// SnakeAid.API/Features/SOS/Services/SOSService.cs -public class SOSService : ISOSService -{ - private readonly ISOSRepository _repository; - private readonly ISnakeIdentificationService _aiService; - private readonly ILocationTrackingService _locationService; - - public async Task CreateSOSAsync(CreateSOSRequest request) - { - // 1. AI identification if photo provided - var aiResult = await _aiService.IdentifyAsync(request.SnakePhotoUrl); - - // 2. Create SOS incident - var sos = new SnakebiteIncident - { - // Map from request... - Severity = DetermineSeverity(aiResult) - }; - - // 3. Start location tracking - await _locationService.StartTrackingAsync(sos.Id); - - // 4. Save and return - await _repository.AddAsync(sos); - return sos.Id; - } -} -``` - -#### Controller Example: -```csharp -// SnakeAid.API/Features/SOS/Controllers/SOSController.cs -[ApiController] -[Route("api/[controller]")] -public class SOSController : ControllerBase -{ - private readonly ISOSService _sosService; - - [HttpPost] - public async Task> CreateSOS(CreateSOSRequest request) - { - var sosId = await _sosService.CreateSOSAsync(request); - return CreatedAtAction(nameof(GetSOS), new { id = sosId }, sosId); - } - - [HttpGet("{id}")] - public async Task> GetSOS(Guid id) - { - var sos = await _sosService.GetSOSAsync(id); - return Ok(sos); - } -} -``` - ---- - -## Phase 3: Other Features Implementation (Week 3) - -### 🎯 Goals -- Implement Catching feature -- Implement Consultation feature -- Implement Community feature - -### Pattern Consistency: -Mỗi feature follow cùng pattern như SOS: -- **Controller** → HTTP endpoints -- **Service** → Business logic -- **Repository** → Data access -- **Models** → Request/Response DTOs -- **Mappings** → AutoMapper profiles - ---- - -## Phase 4: Integration & Polish (Week 4) - -### 🎯 Goals -- Cross-feature integration -- Shared services optimization -- Testing và documentation -- Performance tuning - -### Integration Points: -- **AI Service**: Shared across SOS, Catching, Consultation -- **Location Service**: Shared across SOS, Catching -- **Media Service**: Shared across all features -- **Notification Service**: Cross-feature notifications - ---- - -## 📊 Feature-Based vs DDD Comparison - -| Aspect | Feature-Based MVC | Domain-Driven Design | Winner for SnakeAid | -|--------|------------------|---------------------|-------------------| -| **Setup Time** | 1 week | 4-6 weeks | 🟢 Feature-Based | -| **Learning Curve** | Familiar (MVC) | Steep (DDD patterns) | 🟢 Feature-Based | -| **Team Productivity** | Immediate | Slow initially, fast later | 🟢 Feature-Based | -| **Code Organization** | Good | Perfect | 🟡 Tie | -| **Testability** | Good | Excellent | 🟡 DDD slightly better | -| **Scalability** | Good (vertical scaling) | Excellent (horizontal) | 🟡 Depends on growth | -| **Maintenance** | Simple | Complex initially | 🟢 Feature-Based | -| **Business Alignment** | Good | Perfect | 🟡 DDD better | -| **Deployment** | Single app | Multiple services | 🟢 Feature-Based | - -## 🎯 Final Recommendation: **Feature-Based Architecture** - -### ✅ Choose Feature-Based when: -- Team size < 10 developers ✅ -- Time to market is critical ✅ -- Business domain is not extremely complex ✅ -- Team familiar with MVC patterns ✅ -- Single deployment preferred ✅ - -### ❌ Consider DDD when: -- Large team (10+ developers) -- Complex business rules requiring domain experts -- Multiple deployment contexts needed -- Long-term strategic platform (5+ years) -- Team ready to invest in DDD learning - -## 🚀 Migration Strategy - -### Immediate Actions (This Week): -1. **Reorganize current code** theo features -2. **Break down** `DomainRequestHub` và `DomainResponseHub` thành feature-specific DTOs -3. **Move controllers** vào feature folders -4. **Create shared services** trong Shared folder - -### Next Steps (Next 3 Weeks): -1. **Implement SOS feature** completely với pattern mới -2. **Replicate pattern** cho Catching, Consultation, Community -3. **Testing và integration** -4. **Performance optimization** - ---- - -**Kết luận**: Feature-Based Architecture là **right choice** cho SnakeAid tại thời điểm này. Nó cho phép team ship fast mà vẫn maintain good code organization. Sau này nếu complexity tăng cao, có thể evolve sang DDD. - ---- - -**Next**: [Source Code Implementation](ddd-migration.sourcecode.md) \ No newline at end of file diff --git a/docs/02-layers/domain driven design/ddd-migration.prompt.md b/docs/02-layers/domain driven design/ddd-migration.prompt.md deleted file mode 100644 index 626a848f..00000000 --- a/docs/02-layers/domain driven design/ddd-migration.prompt.md +++ /dev/null @@ -1,551 +0,0 @@ -# Domain-Driven Design Migration - Agent Prompt - -## GitHub Copilot Agent Configuration - -Để tối ưu hóa quá trình development với DDD architecture, cấu hình GitHub Copilot agent với các prompts sau: - ---- - -## 1. Domain Modeling Agent Prompt - -``` -You are a Domain-Driven Design expert specialized in C# and .NET development. When helping with domain modeling: - -CONTEXT: SnakeAid Backend - Emergency snake rescue platform with bounded contexts: -- EmergencyRescue (Core): Life-threatening snake bites -- SnakeCatching (Core): Non-emergency snake removal service -- ExpertConsultation (Core): Expert advice and identification -- SnakeKnowledge (Supporting): Snake species database and AI recognition -- Payment/Wallet (Supporting): Financial transactions -- Identity/Profile (Generic): User management -- Communication (Supporting): Chat, notifications, tracking - -ALWAYS follow these DDD principles: - -1. AGGREGATE DESIGN: - - Keep aggregates small and focused on single business concept - - One repository per aggregate root only - - Enforce invariants within aggregate boundaries - - Use domain events for cross-aggregate communication - -2. DOMAIN LANGUAGE: - - Use ubiquitous language from business domain - - EmergencyCase not "SnakebiteIncident", CatchingRequest not "SnakeCatchingRequest" - - VictimInfo, RescuerProfile, ExpertConsultation, not generic names - -3. RICH DOMAIN MODEL: - - Business logic belongs in domain entities, not services - - Value objects for concepts without identity (LocationPoint, PriceQuote) - - Domain events for business-relevant occurrences - - Avoid anemic domain models - -4. CODE STRUCTURE: -``` -src/ -├── SnakeAid.SharedKernel/ # Common building blocks -└── Contexts/ - └── [ContextName]/ - ├── SnakeAid.[Context].Domain/ # Pure business logic - ├── SnakeAid.[Context].Application/ # Use cases, CQRS - └── SnakeAid.[Context].Infrastructure/ # External concerns -``` - -5. DOMAIN EVENTS PATTERN: - - Events represent business facts: EmergencyCaseCreated, RescuerAssigned - - Use for cross-context integration and side effects - - Events are immutable records with past-tense names - -When I ask for domain modeling help, provide: -- Aggregate root with business methods -- Value objects for complex concepts -- Domain events for business occurrences -- Repository interface for persistence -- Unit tests for domain logic - -Example response format: -```csharp -// Aggregate Root -public class [AggregateName] : AggregateRoot -{ - // Properties with private setters - // Value objects and entities - // Business methods that enforce invariants - // Domain event raising -} - -// Value Object -public class [ValueObjectName] : ValueObject -{ - // Immutable properties - // Validation in constructor - // GetEqualityComponents implementation -} - -// Domain Event -public record [EventName](...) : DomainEvent; -``` -``` - ---- - -## 2. CQRS/Application Layer Agent Prompt - -``` -You are a CQRS expert for .NET applications using MediatR. When helping with application layer: - -CONTEXT: SnakeAid DDD application with separated read/write concerns - -COMMAND HANDLING RULES: -1. Commands represent user intent (CreateEmergencyCase, AssignRescuer) -2. One command = One aggregate modification -3. Commands return simple values (IDs, success/failure) -4. Validate inputs, delegate business logic to domain - -QUERY HANDLING RULES: -1. Queries return DTOs optimized for UI needs -2. Direct database queries for read models, bypass domain layer -3. Include projection and filtering at database level -4. Separate read models for different UI views - -STRUCTURE PATTERN: -``` -Application/ -├── Commands/ -│ └── [UseCase]/ -│ ├── [UseCase]Command.cs -│ ├── [UseCase]Handler.cs -│ └── [UseCase]Validator.cs -├── Queries/ -│ └── [Query]/ -│ ├── [Query]Query.cs -│ ├── [Query]Handler.cs -│ └── [Query]Response.cs -└── EventHandlers/ - └── [Event]Handler.cs -``` - -When I ask for CQRS implementation, provide: - -COMMAND EXAMPLE: -```csharp -// Command -public record [Action]Command(...) : IRequest<[ReturnType]>; - -// Handler -public class [Action]Handler : IRequestHandler<[Action]Command, [ReturnType]> -{ - private readonly I[Aggregate]Repository _repository; - private readonly IUnitOfWork _unitOfWork; - - public async Task<[ReturnType]> Handle([Action]Command request, CancellationToken cancellationToken) - { - // 1. Get aggregate - // 2. Execute business method - // 3. Save via repository - // 4. Return result - } -} -``` - -QUERY EXAMPLE: -```csharp -// Query -public record Get[Resource]Query(...) : IRequest<[Response]>; - -// Response (Read Model) -public class [Response] -{ - // Properties optimized for UI -} - -// Handler with optimized EF queries -public class Get[Resource]Handler : IRequestHandler -{ - public async Task<[Response]> Handle(...) - { - // Direct EF query with projections - return await _context.[DbSet] - .Where(...) - .Select(x => new [Response] { ... }) - .FirstOrDefaultAsync(); - } -} -``` - -VALIDATION with FluentValidation: -```csharp -public class [Command]Validator : AbstractValidator<[Command]> -{ - public [Command]Validator() - { - RuleFor(x => x.Property).NotEmpty().WithMessage("..."); - } -} -``` -``` - ---- - -## 3. Repository Pattern Agent Prompt - -``` -You are a Repository Pattern expert for Entity Framework Core with DDD. - -REPOSITORY RULES: -1. One repository per aggregate root only -2. Repository methods should reflect business operations -3. Use strongly-typed IDs, avoid primitive obsession -4. Include business-relevant query methods -5. Abstract infrastructure concerns from domain - -INTERFACE PATTERN: -```csharp -public interface I[Aggregate]Repository : IRepository<[Aggregate], Guid> -{ - // Business-focused query methods - Task> Get[BusinessConcept]Async(...); - Task<[Aggregate]?> FindBy[BusinessProperty]Async(...); - - // Avoid generic methods like GetAll(), use specific business queries -} -``` - -IMPLEMENTATION PATTERN: -```csharp -public class [Aggregate]Repository : EfRepository<[Aggregate], Guid, [Context]DbContext>, I[Aggregate]Repository -{ - public [Aggregate]Repository([Context]DbContext context) : base(context) { } - - public async Task> Get[BusinessConcept]Async(...) - { - return await DbSet - .Where([business_filter]) - .Include([related_entities]) - .AsSplitQuery() // For multiple includes - .ToListAsync(cancellationToken); - } - - // Override base methods if needed for eager loading - public override async Task<[Aggregate]?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default) - { - return await DbSet - .Include([required_entities]) - .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); - } -} -``` - -EF CONFIGURATION: -```csharp -public class [Aggregate]Configuration : IEntityTypeConfiguration<[Aggregate]> -{ - public void Configure(EntityTypeBuilder<[Aggregate]> builder) - { - builder.HasKey(e => e.Id); - - // Indexes for business queries - builder.HasIndex(e => e.[BusinessProperty]); - builder.HasIndex(e => new { e.[Prop1], e.[Prop2] }); - - // Value object mapping - builder.OwnsOne(e => e.[ValueObject], vo => { - vo.Property(x => x.[Property]).HasColumnName("[column_name]"); - }); - - // Relationships - builder.HasMany(e => e.[Collection]) - .WithOne() - .HasForeignKey("[ForeignKey]") - .OnDelete(DeleteBehavior.Cascade); - } -} -``` - -When I ask for repository implementation, include: -- Business-focused interface methods -- EF Core implementation with proper includes -- Entity configuration for optimal queries -- Indexes for common business queries -``` - ---- - -## 4. Testing Strategy Agent Prompt - -``` -You are a testing expert for DDD applications in .NET. Follow this testing pyramid: - -UNIT TESTS (Domain Layer): -- Test aggregate root business methods -- Test value object validation -- Test domain event raising -- No dependencies on infrastructure - -INTEGRATION TESTS (Application Layer): -- Test command/query handlers -- Use in-memory database or test containers -- Test cross-context communication via events - -API TESTS (Infrastructure): -- Test HTTP endpoints -- Test authentication/authorization -- Test API contracts - -DOMAIN UNIT TEST PATTERN: -```csharp -public class [Aggregate]Tests -{ - [Test] - public void [BusinessMethod]_Should[ExpectedOutcome]_When[Condition]() - { - // Arrange - var aggregate = Create[Valid|Invalid][Aggregate](); - - // Act - var action = () => aggregate.[BusinessMethod]([parameters]); - - // Assert - if (expectsException) - action.Should().Throw().WithMessage("[expected_message]"); - else - { - action.Should().NotThrow(); - aggregate.[Property].Should().Be([expected_value]); - - // Verify domain event - var domainEvent = aggregate.DomainEvents.OfType<[EventType]>().FirstOrDefault(); - domainEvent.Should().NotBeNull(); - } - } - - private [Aggregate] Create[Valid|Invalid][Aggregate]() - { - // Factory methods for test data - } -} -``` - -APPLICATION INTEGRATION TEST PATTERN: -```csharp -public class [Handler]Tests : IClassFixture> -{ - [Test] - public async Task Handle_Should[ExpectedOutcome]_When[Condition]() - { - // Arrange - using var scope = _factory.Services.CreateScope(); - var handler = scope.ServiceProvider.GetRequiredService>(); - var context = scope.ServiceProvider.GetRequiredService<[Context]DbContext>(); - - var command = new [Command]([valid_parameters]); - - // Act - var result = await handler.Handle(command, CancellationToken.None); - - // Assert - result.Should().NotBeNull(); - - // Verify database changes - var entity = await context.[DbSet].FindAsync(result.Id); - entity.Should().NotBeNull(); - entity.[Property].Should().Be([expected_value]); - } -} -``` - -Use these libraries: -- FluentAssertions for readable assertions -- Bogus for test data generation -- Testcontainers for integration tests -- WebApplicationFactory for API tests -``` - ---- - -## 5. Domain Event Handling Agent Prompt - -``` -You are a domain event expert for distributed systems. Handle events for: - -INTRA-CONTEXT EVENTS (Same bounded context): -- Update read models -- Send notifications -- Trigger side effects - -CROSS-CONTEXT EVENTS (Different bounded contexts): -- Use integration events via message bus -- Implement anti-corruption layer -- Handle eventual consistency - -EVENT HANDLER PATTERN: -```csharp -// Domain Event Handler (within same context) -public class [Event]Handler : INotificationHandler<[Event]> -{ - private readonly I[Service] _service; - private readonly ILogger<[Event]Handler> _logger; - - public async Task Handle([Event] notification, CancellationToken cancellationToken) - { - _logger.LogInformation("Processing {Event} for {AggregateId}", - nameof([Event]), notification.[AggregateId]); - - try - { - // Business logic triggered by event - await _service.[BusinessOperation](notification.[Data]); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to process {Event}", nameof([Event])); - throw; - } - } -} - -// Cross-Context Integration Event Handler -public class [Event]IntegrationHandler : INotificationHandler<[Event]> -{ - private readonly IMessageBus _messageBus; - - public async Task Handle([Event] notification, CancellationToken cancellationToken) - { - // Transform to integration event - var integrationEvent = new [External]IntegrationEvent( - notification.[AggregateId], - notification.[RelevantData]); - - // Publish to other contexts - await _messageBus.PublishAsync(integrationEvent, cancellationToken); - } -} -``` - -INTEGRATION EVENT PATTERN: -```csharp -// Integration Event (crosses context boundaries) -public record [Context][Event]IntegrationEvent( - Guid [AggregateId], - [RelevantData] [Data], - DateTime OccurredOn = default -) : IIntegrationEvent -{ - public DateTime OccurredOn { get; } = OccurredOn == default ? DateTime.UtcNow : OccurredOn; - public Guid EventId { get; } = Guid.NewGuid(); -} -``` - -When I ask for event handling, provide: -- Domain event handlers for side effects -- Integration event mapping for cross-context communication -- Error handling and retry strategies -- Event sourcing patterns if needed -``` - ---- - -## 6. Performance Optimization Agent Prompt - -``` -You are a performance expert for DDD applications. Focus on: - -DATABASE OPTIMIZATIONS: -1. Strategic indexes for business queries -2. Eager/lazy loading based on usage patterns -3. Query projections for read models -4. Spatial indexes for location-based queries - -CACHING STRATEGIES: -1. Cache read models, not domain entities -2. Cache by business concepts (nearby emergencies, available rescuers) -3. Invalidate cache on domain events - -EF CORE OPTIMIZATIONS: -```csharp -// Query optimization -public async Task> Get[BusinessConcept]Async(...) -{ - return await _context.[DbSet] - .Where([business_filter]) - .Select(entity => new [ReadModel] // Projection - { - Id = entity.Id, - [RequiredProperties] = entity.[Properties] - }) - .AsNoTracking() // Read-only queries - .AsSplitQuery() // Multiple includes - .ToListAsync(cancellationToken); -} - -// Batch operations -public async Task UpdateMultiple[Aggregates]Async(List<[UpdateRequest]> updates) -{ - var ids = updates.Select(u => u.Id).ToList(); - var entities = await _context.[DbSet] - .Where(e => ids.Contains(e.Id)) - .ToListAsync(); - - foreach (var entity in entities) - { - var update = updates.First(u => u.Id == entity.Id); - entity.[BusinessMethod](update.[Data]); - } - - await _context.SaveChangesAsync(); -} -``` - -CACHING PATTERN: -```csharp -public class Cached[Service] : I[Service] -{ - private readonly I[Service] _inner; - private readonly IDistributedCache _cache; - private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(10); - - public async Task<[Result]> [Method]Async([Parameters]) - { - var cacheKey = $"[business_concept]:[parameter_values]"; - - var cached = await _cache.GetStringAsync(cacheKey); - if (cached != null) - return JsonSerializer.Deserialize<[Result]>(cached); - - var result = await _inner.[Method]Async([parameters]); - - await _cache.SetStringAsync(cacheKey, - JsonSerializer.Serialize(result), - new DistributedCacheEntryOptions - { - AbsoluteExpirationRelativeToNow = CacheDuration - }); - - return result; - } -} - -// Cache invalidation on domain events -public class [Event]CacheInvalidationHandler : INotificationHandler<[Event]> -{ - private readonly IDistributedCache _cache; - - public async Task Handle([Event] notification, CancellationToken cancellationToken) - { - var cacheKeys = GetRelatedCacheKeys(notification); - - var tasks = cacheKeys.Select(key => _cache.RemoveAsync(key)); - await Task.WhenAll(tasks); - } -} -``` - -When I ask for performance optimization, provide: -- Specific EF queries with projections and indexes -- Caching decorators for expensive operations -- Event-based cache invalidation strategies -- Monitoring and metrics for bottlenecks -``` - ---- - -**Final**: Comprehensive DDD migration documentation completed! These agent prompts will guide development teams in implementing Domain-Driven Design patterns consistently across the SnakeAid platform. \ No newline at end of file diff --git a/docs/02-layers/domain driven design/ddd-migration.sourcecode.md b/docs/02-layers/domain driven design/ddd-migration.sourcecode.md deleted file mode 100644 index 31100ef9..00000000 --- a/docs/02-layers/domain driven design/ddd-migration.sourcecode.md +++ /dev/null @@ -1,941 +0,0 @@ -# Domain-Driven Design Migration - Source Code Implementation - -## Architecture Overview - -``` -src/ -├── SnakeAid.SharedKernel/ # Common domain building blocks -├── Contexts/ -│ ├── EmergencyRescue/ # Core Domain 1 -│ │ ├── SnakeAid.EmergencyRescue.Domain/ # Pure Business Logic -│ │ │ ├── Entities/ # Domain Entities (Aggregate Roots) -│ │ │ ├── ValueObjects/ # Immutable Value Objects -│ │ │ ├── Events/ # Domain Events -│ │ │ ├── Services/ # Domain Service Interfaces -│ │ │ └── Repositories/ # Repository Interfaces -│ │ ├── SnakeAid.EmergencyRescue.Application/ # Use Cases & Orchestration -│ │ │ ├── Commands/ # Write Operations (CQRS) -│ │ │ ├── Queries/ # Read Operations (CQRS) -│ │ │ ├── DTOs/ # Data Transfer Objects -│ │ │ │ ├── Requests/ # Input DTOs (thay DomainRequestHub) -│ │ │ │ └── Responses/ # Output DTOs (thay DomainResponseHub) -│ │ │ ├── Mappings/ # AutoMapper Profiles -│ │ │ ├── EventHandlers/ # Domain Event Handlers -│ │ │ └── Services/ # Application Services -│ │ ├── SnakeAid.EmergencyRescue.Infrastructure/ # External Concerns -│ │ │ ├── Persistence/ # Database Layer -│ │ │ ├── Services/ # External Service Implementations -│ │ │ └── EventBus/ # Message Bus -│ │ └── SnakeAid.EmergencyRescue.API/ # HTTP Endpoints -│ │ ├── Controllers/ # REST API Controllers -│ │ ├── Middleware/ # Cross-cutting concerns -│ │ └── Program.cs -│ ├── SnakeCatching/ # Core Domain 2 -│ ├── ExpertConsultation/ # Core Domain 3 -│ ├── SnakeKnowledge/ # Supporting Domain -│ ├── CommunityAndSocial/ # Supporting Domain -│ ├── Payment/ # Supporting Domain -│ ├── Identity/ # Generic Domain -│ └── Communication/ # Supporting Domain -└── SnakeAid.API/ # API Gateway (Optional) -``` - ---- - -## 1. Shared Kernel Implementation - -### Base Domain Classes - -#### Entity Base Class -```csharp -// filepath: src/SnakeAid.SharedKernel/Domain/Entity.cs -using System.ComponentModel.DataAnnotations; - -namespace SnakeAid.SharedKernel.Domain; - -public abstract class Entity : IEquatable> - where TId : IEquatable -{ - public TId Id { get; protected set; } = default!; - - [Required] - public DateTime CreatedAt { get; protected set; } = DateTime.UtcNow; - - [Required] - public DateTime UpdatedAt { get; protected set; } = DateTime.UtcNow; - - protected Entity() { } - - protected Entity(TId id) - { - Id = id; - } - - public bool Equals(Entity? other) - { - return other is not null && Id.Equals(other.Id); - } - - public override bool Equals(object? obj) - { - return obj is Entity entity && Equals(entity); - } - - public override int GetHashCode() - { - return Id.GetHashCode(); - } - - public static bool operator ==(Entity? left, Entity? right) - { - return Equals(left, right); - } - - public static bool operator !=(Entity? left, Entity? right) - { - return !Equals(left, right); - } -} -``` - -#### Aggregate Root Base Class -```csharp -// filepath: src/SnakeAid.SharedKernel/Domain/AggregateRoot.cs -namespace SnakeAid.SharedKernel.Domain; - -public abstract class AggregateRoot : Entity - where TId : IEquatable -{ - private readonly List _domainEvents = new(); - - public IReadOnlyCollection DomainEvents => _domainEvents.AsReadOnly(); - - protected AggregateRoot() { } - - protected AggregateRoot(TId id) : base(id) { } - - protected void AddDomainEvent(IDomainEvent domainEvent) - { - _domainEvents.Add(domainEvent); - } - - public void ClearDomainEvents() - { - _domainEvents.Clear(); - } - - protected void RemoveDomainEvent(IDomainEvent domainEvent) - { - _domainEvents.Remove(domainEvent); - } -} -``` - -#### Value Object Base Class -```csharp -// filepath: src/SnakeAid.SharedKernel/Domain/ValueObject.cs -namespace SnakeAid.SharedKernel.Domain; - -public abstract class ValueObject : IEquatable -{ - protected abstract IEnumerable GetEqualityComponents(); - - public bool Equals(ValueObject? other) - { - return other is not null && - GetEqualityComponents().SequenceEqual(other.GetEqualityComponents()); - } - - public override bool Equals(object? obj) - { - return obj is ValueObject vo && Equals(vo); - } - - public override int GetHashCode() - { - return GetEqualityComponents() - .Select(x => x?.GetHashCode() ?? 0) - .Aggregate((x, y) => x ^ y); - } - - public static bool operator ==(ValueObject? left, ValueObject? right) - { - return Equals(left, right); - } - - public static bool operator !=(ValueObject? left, ValueObject? right) - { - return !Equals(left, right); - } -} -``` - -#### Domain Event Interface -```csharp -// filepath: src/SnakeAid.SharedKernel/Domain/IDomainEvent.cs -using MediatR; - -namespace SnakeAid.SharedKernel.Domain; - -public interface IDomainEvent : INotification -{ - DateTime OccurredOn { get; } - Guid EventId { get; } -} - -public abstract record DomainEvent : IDomainEvent -{ - public DateTime OccurredOn { get; } = DateTime.UtcNow; - public Guid EventId { get; } = Guid.NewGuid(); -} -``` - -### Location Tracking (Shared Kernel) -```csharp -// filepath: src/SnakeAid.SharedKernel/Domain/Location/TrackingSession.cs -using NetTopologySuite.Geometries; - -namespace SnakeAid.SharedKernel.Domain.Location; - -public class TrackingSession : Entity -{ - public Guid SessionId { get; private set; } - public SessionType SessionType { get; private set; } - public bool IsActive { get; private set; } - - public LocationPoint? MemberLocation { get; private set; } - public LocationPoint? RescuerLocation { get; private set; } - - public DateTime? MemberLastUpdate { get; private set; } - public DateTime? RescuerLastUpdate { get; private set; } - - public double? DistanceMeters { get; private set; } - public int? EtaMinutes { get; private set; } - - private TrackingSession() { } - - public static TrackingSession Create(Guid sessionId, SessionType sessionType) - { - return new TrackingSession - { - Id = Guid.NewGuid(), - SessionId = sessionId, - SessionType = sessionType, - IsActive = true, - CreatedAt = DateTime.UtcNow - }; - } - - public void UpdateMemberLocation(double latitude, double longitude) - { - MemberLocation = new LocationPoint(latitude, longitude); - MemberLastUpdate = DateTime.UtcNow; - RecalculateDistance(); - } - - public void UpdateRescuerLocation(double latitude, double longitude) - { - RescuerLocation = new LocationPoint(latitude, longitude); - RescuerLastUpdate = DateTime.UtcNow; - RecalculateDistance(); - } - - public void EndSession() - { - IsActive = false; - } - - private void RecalculateDistance() - { - if (MemberLocation != null && RescuerLocation != null) - { - DistanceMeters = CalculateHaversineDistance(MemberLocation, RescuerLocation); - EtaMinutes = CalculateEta(DistanceMeters.Value); - } - } - - private static double CalculateHaversineDistance(LocationPoint from, LocationPoint to) - { - const double earthRadius = 6371000; // meters - var dLat = ToRadians(to.Latitude - from.Latitude); - var dLon = ToRadians(to.Longitude - from.Longitude); - - var a = Math.Sin(dLat / 2) * Math.Sin(dLat / 2) + - Math.Cos(ToRadians(from.Latitude)) * Math.Cos(ToRadians(to.Latitude)) * - Math.Sin(dLon / 2) * Math.Sin(dLon / 2); - - var c = 2 * Math.Atan2(Math.Sqrt(a), Math.Sqrt(1 - a)); - return earthRadius * c; - } - - private static double ToRadians(double degrees) => degrees * Math.PI / 180; - - private static int CalculateEta(double distanceMeters) - { - const double averageSpeedKmh = 30.0; - var distanceKm = distanceMeters / 1000; - var hours = distanceKm / averageSpeedKmh; - return (int)Math.Ceiling(hours * 60); - } -} - -public record LocationPoint(double Latitude, double Longitude) -{ - public DateTime Timestamp { get; } = DateTime.UtcNow; -} - -public enum SessionType -{ - Emergency = 0, - Catching = 1 -} -``` - -### AI Recognition (Shared Kernel) -```csharp -// filepath: src/SnakeAid.SharedKernel/Domain/AI/SnakeAIRecognitionResult.cs -namespace SnakeAid.SharedKernel.Domain.AI; - -public class SnakeAIRecognitionResult : Entity -{ - public string ImageUrl { get; private set; } - public Guid? SpeciesId { get; private set; } - public string? SpeciesName { get; private set; } - public double ConfidenceScore { get; private set; } - public bool IsVenomous { get; private set; } - public string? DangerLevel { get; private set; } - public List DetectedObjects { get; private set; } = new(); - - private SnakeAIRecognitionResult() { } - - public static SnakeAIRecognitionResult Create(string imageUrl) - { - return new SnakeAIRecognitionResult - { - Id = Guid.NewGuid(), - ImageUrl = imageUrl, - CreatedAt = DateTime.UtcNow - }; - } - - public void SetRecognitionResult( - Guid? speciesId, - string? speciesName, - double confidenceScore, - bool isVenomous, - string? dangerLevel) - { - SpeciesId = speciesId; - SpeciesName = speciesName; - ConfidenceScore = confidenceScore; - IsVenomous = isVenomous; - DangerLevel = dangerLevel; - UpdatedAt = DateTime.UtcNow; - } - - public void AddDetectedObject(double x, double y, double width, double height, double confidence) - { - DetectedObjects.Add(new BoundingBox(x, y, width, height, confidence)); - } -} - -public record BoundingBox(double X, double Y, double Width, double Height, double Confidence); -``` - ---- - -## 2. Emergency Rescue Context Implementation - -### Aggregate Root: EmergencyCase -```csharp -// filepath: src/Contexts/EmergencyRescue/SnakeAid.EmergencyRescue.Domain/Aggregates/EmergencyCase/EmergencyCase.cs -using SnakeAid.SharedKernel.Domain; - -namespace SnakeAid.EmergencyRescue.Domain.Aggregates.EmergencyCase; - -public class EmergencyCase : AggregateRoot -{ - public VictimInfo Victim { get; private set; } - public EmergencyLocation Location { get; private set; } - public EmergencyStatus Status { get; private set; } - public SeverityLevel Severity { get; private set; } - public Guid? AssignedRescuerId { get; private set; } - - // Entities - private readonly List _rescuerRequests = new(); - public IReadOnlyList RescuerRequests => _rescuerRequests.AsReadOnly(); - - public RescueMission? RescueMission { get; private set; } - - // Value Objects - public FirstAidGuidance? FirstAidGuidance { get; private set; } - public HospitalReferral? HospitalReferral { get; private set; } - - // Timeline tracking - private readonly List _timeline = new(); - public IReadOnlyList Timeline => _timeline.AsReadOnly(); - - private EmergencyCase() { } - - public static EmergencyCase Create( - VictimInfo victim, - EmergencyLocation location, - SeverityLevel severity) - { - var emergencyCase = new EmergencyCase - { - Id = Guid.NewGuid(), - Victim = victim, - Location = location, - Severity = severity, - Status = EmergencyStatus.Pending, - CreatedAt = DateTime.UtcNow - }; - - emergencyCase.AddDomainEvent(new EmergencyCaseCreatedEvent( - emergencyCase.Id, - victim.Name, - location.Latitude, - location.Longitude, - severity)); - - emergencyCase.AddTimelineEntry("Emergency case created", "System"); - - return emergencyCase; - } - - public void RequestRescuers(List rescuerIds, int sessionNumber, double radiusKm) - { - if (Status != EmergencyStatus.Pending) - throw new DomainException("Can only request rescuers for pending emergencies"); - - foreach (var rescuerId in rescuerIds) - { - var request = RescuerRequest.Create(Id, rescuerId, sessionNumber, radiusKm); - _rescuerRequests.Add(request); - } - - AddDomainEvent(new RescuersRequestedEvent(Id, rescuerIds, sessionNumber, radiusKm)); - AddTimelineEntry($"Requested {rescuerIds.Count} rescuers in session {sessionNumber}", "System"); - } - - public void AssignRescuer(Guid rescuerId, string rescuerName) - { - if (Status != EmergencyStatus.Pending) - throw new DomainException("Can only assign rescuer to pending cases"); - - if (AssignedRescuerId.HasValue) - throw new DomainException("Rescuer already assigned"); - - AssignedRescuerId = rescuerId; - Status = EmergencyStatus.RescuerAssigned; - - // Mark the accepted request - var acceptedRequest = _rescuerRequests.FirstOrDefault(r => r.RescuerId == rescuerId); - acceptedRequest?.MarkAsAccepted(); - - // Cancel other pending requests - var pendingRequests = _rescuerRequests.Where(r => r.Status == RescuerRequestStatus.Pending); - foreach (var request in pendingRequests) - { - if (request.RescuerId != rescuerId) - request.MarkAsCancelled(); - } - - AddDomainEvent(new RescuerAssignedEvent(Id, rescuerId, rescuerName)); - AddTimelineEntry($"Rescuer assigned: {rescuerName}", "System"); - } - - public void ProvideFirstAidGuidance(FirstAidGuidance guidance) - { - FirstAidGuidance = guidance; - AddDomainEvent(new FirstAidGuidanceProvidedEvent(Id, guidance.Instructions)); - AddTimelineEntry("First aid guidance provided", "AI System"); - } - - public void ReferToHospital(HospitalReferral referral) - { - HospitalReferral = referral; - AddDomainEvent(new HospitalReferredEvent(Id, referral.HospitalId, referral.HospitalName)); - AddTimelineEntry($"Referred to hospital: {referral.HospitalName}", "System"); - } - - public void StartRescueMission() - { - if (Status != EmergencyStatus.RescuerAssigned) - throw new DomainException("Cannot start mission without assigned rescuer"); - - if (RescueMission != null) - throw new DomainException("Mission already started"); - - RescueMission = Domain.Aggregates.EmergencyCase.RescueMission.Create(Id, AssignedRescuerId!.Value); - Status = EmergencyStatus.InProgress; - - AddDomainEvent(new RescueMissionStartedEvent(Id, RescueMission.Id)); - AddTimelineEntry("Rescue mission started", "Rescuer"); - } - - public void CompleteEmergency(string outcome, string notes = "") - { - if (Status != EmergencyStatus.InProgress) - throw new DomainException("Can only complete emergency that is in progress"); - - Status = EmergencyStatus.Completed; - RescueMission?.Complete(outcome, notes); - - AddDomainEvent(new EmergencyCompletedEvent(Id, outcome)); - AddTimelineEntry($"Emergency completed: {outcome}", "Rescuer"); - } - - public void CancelEmergency(string reason) - { - if (Status == EmergencyStatus.Completed) - throw new DomainException("Cannot cancel completed emergency"); - - Status = EmergencyStatus.Cancelled; - RescueMission?.Cancel(reason); - - // Cancel all pending rescuer requests - foreach (var request in _rescuerRequests.Where(r => r.Status == RescuerRequestStatus.Pending)) - { - request.MarkAsCancelled(); - } - - AddDomainEvent(new EmergencyCancelledEvent(Id, reason)); - AddTimelineEntry($"Emergency cancelled: {reason}", "System"); - } - - public void EscalateSeverity(string reason) - { - if (Severity == SeverityLevel.Critical) - return; // Already at maximum severity - - var oldSeverity = Severity; - Severity = Severity switch - { - SeverityLevel.Low => SeverityLevel.Medium, - SeverityLevel.Medium => SeverityLevel.High, - SeverityLevel.High => SeverityLevel.Critical, - _ => Severity - }; - - AddDomainEvent(new SeverityEscalatedEvent(Id, oldSeverity, Severity, reason)); - AddTimelineEntry($"Severity escalated from {oldSeverity} to {Severity}: {reason}", "AI System"); - } - - private void AddTimelineEntry(string description, string actor) - { - _timeline.Add(new EmergencyTimelineEntry(DateTime.UtcNow, description, actor)); - } -} -``` - -### Value Objects -```csharp -// filepath: src/Contexts/EmergencyRescue/SnakeAid.EmergencyRescue.Domain/ValueObjects/VictimInfo.cs -using SnakeAid.SharedKernel.Domain; - -namespace SnakeAid.EmergencyRescue.Domain.ValueObjects; - -public class VictimInfo : ValueObject -{ - public string Name { get; } - public string PhoneNumber { get; } - public string? BiteDescription { get; } - public string? Symptoms { get; } - - public VictimInfo(string name, string phoneNumber, string? biteDescription = null, string? symptoms = null) - { - if (string.IsNullOrWhiteSpace(name)) - throw new ArgumentException("Name cannot be empty", nameof(name)); - if (string.IsNullOrWhiteSpace(phoneNumber)) - throw new ArgumentException("Phone number cannot be empty", nameof(phoneNumber)); - - Name = name; - PhoneNumber = phoneNumber; - BiteDescription = biteDescription; - Symptoms = symptoms; - } - - protected override IEnumerable GetEqualityComponents() - { - yield return Name; - yield return PhoneNumber; - yield return BiteDescription ?? ""; - yield return Symptoms ?? ""; - } -} - -// filepath: src/Contexts/EmergencyRescue/SnakeAid.EmergencyRescue.Domain/ValueObjects/EmergencyLocation.cs -public class EmergencyLocation : ValueObject -{ - public double Latitude { get; } - public double Longitude { get; } - public string Address { get; } - public string? Landmark { get; } - - public EmergencyLocation(double latitude, double longitude, string address, string? landmark = null) - { - if (latitude < -90 || latitude > 90) - throw new ArgumentException("Invalid latitude", nameof(latitude)); - if (longitude < -180 || longitude > 180) - throw new ArgumentException("Invalid longitude", nameof(longitude)); - - Latitude = latitude; - Longitude = longitude; - Address = address ?? throw new ArgumentNullException(nameof(address)); - Landmark = landmark; - } - - protected override IEnumerable GetEqualityComponents() - { - yield return Latitude; - yield return Longitude; - yield return Address; - yield return Landmark ?? ""; - } -} - -// filepath: src/Contexts/EmergencyRescue/SnakeAid.EmergencyRescue.Domain/ValueObjects/EmergencyStatus.cs -public enum EmergencyStatus -{ - Pending = 0, - RescuerAssigned = 1, - InProgress = 2, - Completed = 3, - Cancelled = 4 -} - -public enum SeverityLevel -{ - Low = 0, - Medium = 1, - High = 2, - Critical = 3 -} -``` - -### Domain Events -```csharp -// filepath: src/Contexts/EmergencyRescue/SnakeAid.EmergencyRescue.Domain/Events/EmergencyCaseCreatedEvent.cs -using SnakeAid.SharedKernel.Domain; - -namespace SnakeAid.EmergencyRescue.Domain.Events; - -public record EmergencyCaseCreatedEvent( - Guid EmergencyId, - string VictimName, - double Latitude, - double Longitude, - SeverityLevel Severity -) : DomainEvent; - -public record RescuerAssignedEvent( - Guid EmergencyId, - Guid RescuerId, - string RescuerName -) : DomainEvent; - -public record FirstAidGuidanceProvidedEvent( - Guid EmergencyId, - string Instructions -) : DomainEvent; - -public record HospitalReferredEvent( - Guid EmergencyId, - Guid HospitalId, - string HospitalName -) : DomainEvent; - -public record EmergencyCompletedEvent( - Guid EmergencyId, - string Outcome -) : DomainEvent; -``` - -### Application Layer - CQRS -```csharp -// filepath: src/Contexts/EmergencyRescue/SnakeAid.EmergencyRescue.Application/Commands/CreateEmergencyCase/CreateEmergencyCaseCommand.cs -using MediatR; - -namespace SnakeAid.EmergencyRescue.Application.Commands.CreateEmergencyCase; - -public record CreateEmergencyCaseCommand( - string VictimName, - string PhoneNumber, - string? BiteDescription, - string? Symptoms, - double Latitude, - double Longitude, - string Address, - string? Landmark, - string? SnakePhotoUrl -) : IRequest; - -// filepath: src/Contexts/EmergencyRescue/SnakeAid.EmergencyRescue.Application/Commands/CreateEmergencyCase/CreateEmergencyCaseHandler.cs -using SnakeAid.EmergencyRescue.Domain.Aggregates.EmergencyCase; -using SnakeAid.SharedKernel.Domain.AI; - -public class CreateEmergencyCaseHandler : IRequestHandler -{ - private readonly IEmergencyCaseRepository _repository; - private readonly ISnakeIdentificationService _aiService; - private readonly IFirstAidService _firstAidService; - private readonly IUnitOfWork _unitOfWork; - - public CreateEmergencyCaseHandler( - IEmergencyCaseRepository repository, - ISnakeIdentificationService aiService, - IFirstAidService firstAidService, - IUnitOfWork unitOfWork) - { - _repository = repository; - _aiService = aiService; - _firstAidService = firstAidService; - _unitOfWork = unitOfWork; - } - - public async Task Handle(CreateEmergencyCaseCommand request, CancellationToken cancellationToken) - { - // 1. AI Snake Identification (if photo provided) - SnakeAIRecognitionResult? aiResult = null; - if (!string.IsNullOrEmpty(request.SnakePhotoUrl)) - { - aiResult = await _aiService.IdentifySnakeAsync(request.SnakePhotoUrl); - } - - // 2. Determine severity based on AI result - var severity = DetermineSeverity(aiResult, request.Symptoms); - - // 3. Create domain entities - var victim = new VictimInfo( - request.VictimName, - request.PhoneNumber, - request.BiteDescription, - request.Symptoms); - - var location = new EmergencyLocation( - request.Latitude, - request.Longitude, - request.Address, - request.Landmark); - - // 4. Create aggregate - var emergencyCase = EmergencyCase.Create(victim, location, severity); - - // 5. Generate first aid guidance - var guidance = await _firstAidService.GenerateGuidanceAsync(aiResult, severity); - emergencyCase.ProvideFirstAidGuidance(guidance); - - // 6. Persist - await _repository.AddAsync(emergencyCase, cancellationToken); - await _unitOfWork.SaveChangesAsync(cancellationToken); - - return emergencyCase.Id; - } - - private SeverityLevel DetermineSeverity(SnakeAIRecognitionResult? aiResult, string? symptoms) - { - // AI detected venomous snake - if (aiResult?.IsVenomous == true && aiResult.ConfidenceScore > 0.8) - return SeverityLevel.Critical; - - // Severe symptoms - if (symptoms?.Contains("difficulty breathing") == true || - symptoms?.Contains("swelling") == true) - return SeverityLevel.High; - - // Default to medium for any snake bite - return SeverityLevel.Medium; - } -} -``` - ---- - -## 3. Repository Pattern & Infrastructure - -### Repository Interface -```csharp -// filepath: src/SnakeAid.SharedKernel/Domain/IRepository.cs -namespace SnakeAid.SharedKernel.Domain; - -public interface IRepository - where TEntity : AggregateRoot - where TId : IEquatable -{ - Task GetByIdAsync(TId id, CancellationToken cancellationToken = default); - Task AddAsync(TEntity entity, CancellationToken cancellationToken = default); - Task UpdateAsync(TEntity entity, CancellationToken cancellationToken = default); - Task DeleteAsync(TEntity entity, CancellationToken cancellationToken = default); -} - -public interface IUnitOfWork -{ - Task SaveChangesAsync(CancellationToken cancellationToken = default); -} -``` - -### EF Core Repository Implementation -```csharp -// filepath: src/Contexts/EmergencyRescue/SnakeAid.EmergencyRescue.Infrastructure/Persistence/EmergencyCaseRepository.cs -using Microsoft.EntityFrameworkCore; -using SnakeAid.EmergencyRescue.Domain.Aggregates.EmergencyCase; -using SnakeAid.SharedKernel.Domain; - -namespace SnakeAid.EmergencyRescue.Infrastructure.Persistence; - -public class EmergencyCaseRepository : IEmergencyCaseRepository -{ - private readonly EmergencyRescueDbContext _context; - - public EmergencyCaseRepository(EmergencyRescueDbContext context) - { - _context = context; - } - - public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) - { - return await _context.EmergencyCases - .Include(e => e.RescuerRequests) - .Include(e => e.RescueMission) - .FirstOrDefaultAsync(e => e.Id == id, cancellationToken); - } - - public async Task AddAsync(EmergencyCase entity, CancellationToken cancellationToken = default) - { - await _context.EmergencyCases.AddAsync(entity, cancellationToken); - } - - public Task UpdateAsync(EmergencyCase entity, CancellationToken cancellationToken = default) - { - _context.EmergencyCases.Update(entity); - return Task.CompletedTask; - } - - public Task DeleteAsync(EmergencyCase entity, CancellationToken cancellationToken = default) - { - _context.EmergencyCases.Remove(entity); - return Task.CompletedTask; - } - - public async Task> GetActiveEmergenciesAsync(CancellationToken cancellationToken = default) - { - return await _context.EmergencyCases - .Where(e => e.Status == EmergencyStatus.Pending || e.Status == EmergencyStatus.InProgress) - .ToListAsync(cancellationToken); - } -} -``` - -### Domain Event Publishing -```csharp -// filepath: src/SnakeAid.SharedKernel/Infrastructure/DomainEventDispatcher.cs -using MediatR; -using Microsoft.EntityFrameworkCore; -using SnakeAid.SharedKernel.Domain; - -namespace SnakeAid.SharedKernel.Infrastructure; - -public class DomainEventDispatcher : IDomainEventDispatcher -{ - private readonly IMediator _mediator; - - public DomainEventDispatcher(IMediator mediator) - { - _mediator = mediator; - } - - public async Task DispatchEventsAsync(TContext context, CancellationToken cancellationToken = default) - where TContext : DbContext - { - var domainEntities = context.ChangeTracker - .Entries>() - .Where(x => x.Entity.DomainEvents.Any()) - .ToList(); - - var domainEvents = domainEntities - .SelectMany(x => x.Entity.DomainEvents) - .ToList(); - - domainEntities.ForEach(entity => entity.Entity.ClearDomainEvents()); - - foreach (var domainEvent in domainEvents) - { - await _mediator.Publish(domainEvent, cancellationToken); - } - } -} -``` - ---- - -## 4. API Gateway Integration - -### API Controller -```csharp -// filepath: src/SnakeAid.API/Controllers/EmergencyController.cs -using MediatR; -using Microsoft.AspNetCore.Mvc; -using SnakeAid.EmergencyRescue.Application.Commands.CreateEmergencyCase; - -namespace SnakeAid.API.Controllers; - -[ApiController] -[Route("api/[controller]")] -public class EmergencyController : ControllerBase -{ - private readonly IMediator _mediator; - - public EmergencyController(IMediator mediator) - { - _mediator = mediator; - } - - [HttpPost] - public async Task> CreateEmergency(CreateEmergencyRequest request) - { - var command = new CreateEmergencyCaseCommand( - request.VictimName, - request.PhoneNumber, - request.BiteDescription, - request.Symptoms, - request.Latitude, - request.Longitude, - request.Address, - request.Landmark, - request.SnakePhotoUrl); - - var emergencyId = await _mediator.Send(command); - - return CreatedAtAction(nameof(GetEmergency), new { id = emergencyId }, emergencyId); - } - - [HttpGet("{id}")] - public async Task> GetEmergency(Guid id) - { - var query = new GetEmergencyCaseQuery(id); - var result = await _mediator.Send(query); - - if (result == null) - return NotFound(); - - return Ok(result); - } -} - -public record CreateEmergencyRequest( - string VictimName, - string PhoneNumber, - string? BiteDescription, - string? Symptoms, - double Latitude, - double Longitude, - string Address, - string? Landmark, - string? SnakePhotoUrl); -``` - ---- - -**Next**: [Usage Guide](ddd-migration.usageguide.md) \ No newline at end of file diff --git a/docs/02-layers/domain driven design/ddd-migration.usageguide.md b/docs/02-layers/domain driven design/ddd-migration.usageguide.md deleted file mode 100644 index 9376675f..00000000 --- a/docs/02-layers/domain driven design/ddd-migration.usageguide.md +++ /dev/null @@ -1,746 +0,0 @@ -# Domain-Driven Design Migration - Usage Guide - -## Development Workflow - -### Prerequisites -- .NET 8.0 SDK -- PostgreSQL with PostGIS extension -- Redis (for caching) -- Visual Studio 2022 or VS Code - -### Setup Development Environment - -#### 1. Clone and Setup -```bash -git clone -cd SnakeAid.Backend -dotnet restore -``` - -#### 2. Database Setup -```sql --- PostgreSQL with PostGIS -CREATE DATABASE SnakeAidDDD; -CREATE EXTENSION postgis; - --- Connection string in appsettings.json -"ConnectionStrings": { - "DefaultConnection": "Host=localhost;Database=SnakeAidDDD;Username=postgres;Password=your_password" -} -``` - -#### 3. Run Migrations -```bash -# Emergency Rescue Context -dotnet ef database update --context EmergencyRescueDbContext - -# Snake Catching Context -dotnet ef database update --context SnakeCatchingDbContext - -# Other contexts... -``` - ---- - -## Creating New Bounded Context - -### Step 1: Create Project Structure -```bash -mkdir -p "src/Contexts/NewContext" -cd "src/Contexts/NewContext" - -# Domain Layer -dotnet new classlib -n SnakeAid.NewContext.Domain -mkdir -p SnakeAid.NewContext.Domain/{Entities,ValueObjects,Events,Services,Repositories} - -# Application Layer -dotnet new classlib -n SnakeAid.NewContext.Application -mkdir -p SnakeAid.NewContext.Application/{Commands,Queries,DTOs,Mappings,EventHandlers,Services} -mkdir -p SnakeAid.NewContext.Application/DTOs/{Requests,Responses} - -# Infrastructure Layer -dotnet new classlib -n SnakeAid.NewContext.Infrastructure -mkdir -p SnakeAid.NewContext.Infrastructure/{Persistence,Services,EventBus} -mkdir -p SnakeAid.NewContext.Infrastructure/Persistence/{Repositories,Configurations} - -# API Layer (Optional - có thể dùng chung API Gateway) -dotnet new webapi -n SnakeAid.NewContext.API -mkdir -p SnakeAid.NewContext.API/{Controllers,Middleware,Filters} -``` - -## 📂 Folder Naming Conventions - -### Domain Layer Folders: -- **`Entities/`** - Domain entities và aggregate roots (thay vì `Domains/`) -- **`ValueObjects/`** - Immutable value objects -- **`Events/`** - Domain events -- **`Services/`** - Domain service interfaces -- **`Repositories/`** - Repository interfaces (chỉ interfaces) - -### Application Layer Folders: -- **`Commands/`** - Write operations theo CQRS pattern -- **`Queries/`** - Read operations theo CQRS pattern -- **`DTOs/Requests/`** - Input DTOs (thay thế cho `DomainRequestHub`) -- **`DTOs/Responses/`** - Output DTOs (thay thế cho `DomainResponseHub`) -- **`Mappings/`** - AutoMapper profiles -- **`EventHandlers/`** - Domain event handlers -- **`Services/`** - Application services (orchestration) - -### Infrastructure Layer Folders: -- **`Persistence/`** - Database concerns (DbContext, migrations) -- **`Persistence/Repositories/`** - Repository implementations -- **`Persistence/Configurations/`** - EF Core configurations -- **`Services/`** - External service implementations -- **`EventBus/`** - Message bus implementations - -### API Layer Folders: -- **`Controllers/`** - HTTP endpoints -- **`Middleware/`** - Custom middleware -- **`Filters/`** - Action filters - -### Step 2: Reference SharedKernel -```xml - - -``` - -### Step 3: Create Aggregate Root -```csharp -// Example: NewAggregate.cs -using SnakeAid.SharedKernel.Domain; - -namespace SnakeAid.NewContext.Domain.Aggregates.NewAggregate; - -public class NewAggregate : AggregateRoot -{ - // Properties - public string Name { get; private set; } - public AggregateStatus Status { get; private set; } - - // Constructor - private NewAggregate() { } - - // Factory method - public static NewAggregate Create(string name) - { - var aggregate = new NewAggregate - { - Id = Guid.NewGuid(), - Name = name, - Status = AggregateStatus.Active, - CreatedAt = DateTime.UtcNow - }; - - aggregate.AddDomainEvent(new NewAggregateCreatedEvent(aggregate.Id, name)); - return aggregate; - } - - // Business methods - public void UpdateName(string newName) - { - if (string.IsNullOrWhiteSpace(newName)) - throw new DomainException("Name cannot be empty"); - - var oldName = Name; - Name = newName; - UpdatedAt = DateTime.UtcNow; - - AddDomainEvent(new NewAggregateUpdatedEvent(Id, oldName, newName)); - } -} -``` - ---- - -## Working with Domain Events - -### 1. Creating Domain Events -```csharp -// filepath: src/Contexts/NewContext/SnakeAid.NewContext.Domain/Events/NewAggregateCreatedEvent.cs -using SnakeAid.SharedKernel.Domain; - -namespace SnakeAid.NewContext.Domain.Events; - -public record NewAggregateCreatedEvent( - Guid AggregateId, - string Name -) : DomainEvent; -``` - -### 2. Event Handlers -```csharp -// filepath: src/Contexts/NewContext/SnakeAid.NewContext.Application/EventHandlers/NewAggregateCreatedEventHandler.cs -using MediatR; -using SnakeAid.NewContext.Domain.Events; - -namespace SnakeAid.NewContext.Application.EventHandlers; - -public class NewAggregateCreatedEventHandler : INotificationHandler -{ - private readonly ILogger _logger; - - public NewAggregateCreatedEventHandler(ILogger logger) - { - _logger = logger; - } - - public Task Handle(NewAggregateCreatedEvent notification, CancellationToken cancellationToken) - { - _logger.LogInformation("New aggregate created: {AggregateId} - {Name}", - notification.AggregateId, notification.Name); - - // Additional logic here (send notification, update read model, etc.) - - return Task.CompletedTask; - } -} -``` - -### 3. Cross-Context Events -```csharp -// When EmergencyCase is created, notify other contexts -public class EmergencyCaseCreatedEventHandler : INotificationHandler -{ - private readonly ILocationTrackingService _locationService; - private readonly INotificationService _notificationService; - - public async Task Handle(EmergencyCaseCreatedEvent notification, CancellationToken cancellationToken) - { - // Start location tracking - await _locationService.StartTrackingAsync(notification.EmergencyId, SessionType.Emergency); - - // Send push notifications to nearby rescuers - await _notificationService.NotifyNearbyRescuersAsync( - notification.Latitude, - notification.Longitude, - notification.Severity); - } -} -``` - ---- - -## CQRS Implementation - -### 1. Command Pattern -```csharp -// Command -public record UpdateEmergencyStatusCommand( - Guid EmergencyId, - EmergencyStatus NewStatus, - string? Notes -) : IRequest; - -// Handler -public class UpdateEmergencyStatusHandler : IRequestHandler -{ - private readonly IEmergencyCaseRepository _repository; - private readonly IUnitOfWork _unitOfWork; - - public UpdateEmergencyStatusHandler( - IEmergencyCaseRepository repository, - IUnitOfWork unitOfWork) - { - _repository = repository; - _unitOfWork = unitOfWork; - } - - public async Task Handle(UpdateEmergencyStatusCommand request, CancellationToken cancellationToken) - { - var emergencyCase = await _repository.GetByIdAsync(request.EmergencyId, cancellationToken); - - if (emergencyCase == null) - return false; - - // Business logic through domain methods - switch (request.NewStatus) - { - case EmergencyStatus.Completed: - emergencyCase.CompleteEmergency(request.Notes ?? "Completed"); - break; - case EmergencyStatus.Cancelled: - emergencyCase.CancelEmergency(request.Notes ?? "Cancelled"); - break; - default: - throw new DomainException($"Cannot directly set status to {request.NewStatus}"); - } - - await _repository.UpdateAsync(emergencyCase, cancellationToken); - await _unitOfWork.SaveChangesAsync(cancellationToken); - - return true; - } -} -``` - -### 2. Query Pattern -```csharp -// Query -public record GetEmergencyDashboardQuery( - Guid RescuerId, - int PageSize = 10, - int PageNumber = 1 -) : IRequest; - -// Response DTO (Read Model) -public class EmergencyDashboardResponse -{ - public List ActiveEmergencies { get; set; } = new(); - public List MyAssignedEmergencies { get; set; } = new(); - public EmergencyStatistics Statistics { get; set; } = new(); - public int TotalPages { get; set; } -} - -public class EmergencySummary -{ - public Guid Id { get; set; } - public string VictimName { get; set; } = ""; - public string Location { get; set; } = ""; - public SeverityLevel Severity { get; set; } - public EmergencyStatus Status { get; set; } - public DateTime CreatedAt { get; set; } - public double? DistanceKm { get; set; } -} - -// Handler với optimized read -public class GetEmergencyDashboardHandler : IRequestHandler -{ - private readonly IEmergencyReadModelService _readModelService; - - public GetEmergencyDashboardHandler(IEmergencyReadModelService readModelService) - { - _readModelService = readModelService; - } - - public async Task Handle( - GetEmergencyDashboardQuery request, - CancellationToken cancellationToken) - { - return await _readModelService.GetDashboardAsync( - request.RescuerId, - request.PageSize, - request.PageNumber, - cancellationToken); - } -} -``` - ---- - -## Repository Patterns - -### 1. Generic Repository -```csharp -// filepath: src/SnakeAid.SharedKernel/Infrastructure/EfRepository.cs -using Microsoft.EntityFrameworkCore; -using SnakeAid.SharedKernel.Domain; - -namespace SnakeAid.SharedKernel.Infrastructure; - -public abstract class EfRepository : IRepository - where TEntity : AggregateRoot - where TId : IEquatable - where TContext : DbContext -{ - protected readonly TContext Context; - protected readonly DbSet DbSet; - - protected EfRepository(TContext context) - { - Context = context; - DbSet = context.Set(); - } - - public virtual async Task GetByIdAsync(TId id, CancellationToken cancellationToken = default) - { - return await DbSet.FindAsync(new object[] { id }, cancellationToken); - } - - public virtual async Task AddAsync(TEntity entity, CancellationToken cancellationToken = default) - { - await DbSet.AddAsync(entity, cancellationToken); - } - - public virtual Task UpdateAsync(TEntity entity, CancellationToken cancellationToken = default) - { - DbSet.Update(entity); - return Task.CompletedTask; - } - - public virtual Task DeleteAsync(TEntity entity, CancellationToken cancellationToken = default) - { - DbSet.Remove(entity); - return Task.CompletedTask; - } -} -``` - -### 2. Specific Repository with Business Methods -```csharp -// Interface -public interface IEmergencyCaseRepository : IRepository -{ - Task> GetActiveEmergenciesAsync(CancellationToken cancellationToken = default); - Task> GetNearbyEmergenciesAsync(double latitude, double longitude, double radiusKm, CancellationToken cancellationToken = default); - Task GetByIdWithDetailsAsync(Guid id, CancellationToken cancellationToken = default); -} - -// Implementation -public class EmergencyCaseRepository : EfRepository, IEmergencyCaseRepository -{ - public EmergencyCaseRepository(EmergencyRescueDbContext context) : base(context) { } - - public async Task> GetActiveEmergenciesAsync(CancellationToken cancellationToken = default) - { - return await DbSet - .Where(e => e.Status == EmergencyStatus.Pending || e.Status == EmergencyStatus.InProgress) - .OrderByDescending(e => e.Severity) - .ThenBy(e => e.CreatedAt) - .ToListAsync(cancellationToken); - } - - public async Task> GetNearbyEmergenciesAsync( - double latitude, - double longitude, - double radiusKm, - CancellationToken cancellationToken = default) - { - // PostGIS spatial query - var point = new NetTopologySuite.Geometries.Point(longitude, latitude) { SRID = 4326 }; - - return await DbSet - .Where(e => e.Status == EmergencyStatus.Pending) - .Where(e => e.Location.LocationCoordinates.Distance(point) <= radiusKm * 1000) // Convert to meters - .OrderBy(e => e.Location.LocationCoordinates.Distance(point)) - .ToListAsync(cancellationToken); - } - - public async Task GetByIdWithDetailsAsync(Guid id, CancellationToken cancellationToken = default) - { - return await DbSet - .Include(e => e.RescuerRequests) - .Include(e => e.RescueMission) - .AsSplitQuery() - .FirstOrDefaultAsync(e => e.Id == id, cancellationToken); - } -} -``` - ---- - -## Unit of Work Pattern - -### 1. UnitOfWork Implementation -```csharp -// filepath: src/Contexts/EmergencyRescue/SnakeAid.EmergencyRescue.Infrastructure/Persistence/EmergencyRescueUnitOfWork.cs -using SnakeAid.SharedKernel.Domain; -using SnakeAid.SharedKernel.Infrastructure; - -namespace SnakeAid.EmergencyRescue.Infrastructure.Persistence; - -public class EmergencyRescueUnitOfWork : IUnitOfWork -{ - private readonly EmergencyRescueDbContext _context; - private readonly IDomainEventDispatcher _eventDispatcher; - - public EmergencyRescueUnitOfWork( - EmergencyRescueDbContext context, - IDomainEventDispatcher eventDispatcher) - { - _context = context; - _eventDispatcher = eventDispatcher; - } - - public async Task SaveChangesAsync(CancellationToken cancellationToken = default) - { - // Dispatch domain events before saving - await _eventDispatcher.DispatchEventsAsync(_context, cancellationToken); - - // Save changes - return await _context.SaveChangesAsync(cancellationToken); - } -} -``` - ---- - -## Testing Strategies - -### 1. Domain Unit Tests -```csharp -// filepath: tests/EmergencyRescue.UnitTests/Domain/EmergencyCaseTests.cs -using SnakeAid.EmergencyRescue.Domain.Aggregates.EmergencyCase; -using SnakeAid.EmergencyRescue.Domain.Events; - -namespace EmergencyRescue.UnitTests.Domain; - -public class EmergencyCaseTests -{ - [Test] - public void Create_ShouldCreateEmergencyCase_WithValidData() - { - // Arrange - var victim = new VictimInfo("John Doe", "0123456789", "Snake bite on leg"); - var location = new EmergencyLocation(10.762622, 106.660172, "District 1, HCMC"); - var severity = SeverityLevel.High; - - // Act - var emergencyCase = EmergencyCase.Create(victim, location, severity); - - // Assert - emergencyCase.Should().NotBeNull(); - emergencyCase.Id.Should().NotBeEmpty(); - emergencyCase.Status.Should().Be(EmergencyStatus.Pending); - emergencyCase.Severity.Should().Be(SeverityLevel.High); - - // Domain event should be raised - var domainEvent = emergencyCase.DomainEvents.OfType().FirstOrDefault(); - domainEvent.Should().NotBeNull(); - domainEvent!.EmergencyId.Should().Be(emergencyCase.Id); - } - - [Test] - public void AssignRescuer_ShouldThrowException_WhenAlreadyAssigned() - { - // Arrange - var emergencyCase = CreateValidEmergencyCase(); - var rescuerId1 = Guid.NewGuid(); - var rescuerId2 = Guid.NewGuid(); - - emergencyCase.AssignRescuer(rescuerId1, "Rescuer 1"); - - // Act & Assert - var action = () => emergencyCase.AssignRescuer(rescuerId2, "Rescuer 2"); - action.Should().Throw() - .WithMessage("Rescuer already assigned"); - } - - private EmergencyCase CreateValidEmergencyCase() - { - var victim = new VictimInfo("Test Victim", "0123456789"); - var location = new EmergencyLocation(10.762622, 106.660172, "Test Address"); - return EmergencyCase.Create(victim, location, SeverityLevel.Medium); - } -} -``` - -### 2. Application Layer Integration Tests -```csharp -// filepath: tests/EmergencyRescue.IntegrationTests/Application/CreateEmergencyCaseHandlerTests.cs -using Microsoft.EntityFrameworkCore; -using SnakeAid.EmergencyRescue.Application.Commands.CreateEmergencyCase; - -namespace EmergencyRescue.IntegrationTests.Application; - -public class CreateEmergencyCaseHandlerTests : IClassFixture> -{ - private readonly WebApplicationFactory _factory; - - public CreateEmergencyCaseHandlerTests(WebApplicationFactory factory) - { - _factory = factory; - } - - [Test] - public async Task Handle_ShouldCreateEmergencyCase_WithValidCommand() - { - // Arrange - using var scope = _factory.Services.CreateScope(); - var handler = scope.ServiceProvider.GetRequiredService>(); - var context = scope.ServiceProvider.GetRequiredService(); - - var command = new CreateEmergencyCaseCommand( - "John Doe", - "0123456789", - "Snake bite on leg", - "Swelling and pain", - 10.762622, - 106.660172, - "District 1, HCMC", - "Near Ben Thanh Market", - null); - - // Act - var result = await handler.Handle(command, CancellationToken.None); - - // Assert - result.Should().NotBeEmpty(); - - var emergencyCase = await context.EmergencyCases.FindAsync(result); - emergencyCase.Should().NotBeNull(); - emergencyCase!.Victim.Name.Should().Be("John Doe"); - emergencyCase.Status.Should().Be(EmergencyStatus.Pending); - } -} -``` - -### 3. API Integration Tests -```csharp -// filepath: tests/SnakeAid.API.IntegrationTests/Controllers/EmergencyControllerTests.cs -public class EmergencyControllerTests : IClassFixture> -{ - private readonly HttpClient _client; - - public EmergencyControllerTests(WebApplicationFactory factory) - { - _client = factory.CreateClient(); - } - - [Test] - public async Task CreateEmergency_ShouldReturn201_WithValidRequest() - { - // Arrange - var request = new CreateEmergencyRequest( - "John Doe", - "0123456789", - "Snake bite on leg", - "Swelling and pain", - 10.762622, - 106.660172, - "District 1, HCMC", - "Near Ben Thanh Market", - null); - - // Act - var response = await _client.PostAsJsonAsync("/api/emergency", request); - - // Assert - response.StatusCode.Should().Be(HttpStatusCode.Created); - - var emergencyId = await response.Content.ReadFromJsonAsync(); - emergencyId.Should().NotBeEmpty(); - } -} -``` - ---- - -## Performance Optimization - -### 1. Database Optimizations -```csharp -// EF Core Configuration -public class EmergencyCaseConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.HasKey(e => e.Id); - - // Indexes for common queries - builder.HasIndex(e => e.Status); - builder.HasIndex(e => new { e.Status, e.CreatedAt }); - builder.HasIndex(e => new { e.Status, e.Severity }); - - // Spatial index for location queries - builder.Property(e => e.Location.LocationCoordinates) - .HasColumnType("geometry(Point, 4326)"); - builder.HasIndex(e => e.Location.LocationCoordinates) - .HasMethod("gist"); - - // Value object mapping - builder.OwnsOne(e => e.Victim, victim => - { - victim.Property(v => v.Name).HasMaxLength(200); - victim.Property(v => v.PhoneNumber).HasMaxLength(20); - }); - - // Collection mapping - builder.HasMany(e => e.RescuerRequests) - .WithOne() - .HasForeignKey("EmergencyCaseId") - .OnDelete(DeleteBehavior.Cascade); - } -} -``` - -### 2. Caching Strategy -```csharp -// filepath: src/SnakeAid.SharedKernel/Infrastructure/CachedRepository.cs -public class CachedEmergencyCaseRepository : IEmergencyCaseRepository -{ - private readonly IEmergencyCaseRepository _repository; - private readonly IMemoryCache _cache; - private readonly TimeSpan _cacheExpiry = TimeSpan.FromMinutes(10); - - public CachedEmergencyCaseRepository( - IEmergencyCaseRepository repository, - IMemoryCache cache) - { - _repository = repository; - _cache = cache; - } - - public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) - { - var cacheKey = $"emergency-{id}"; - - if (_cache.TryGetValue(cacheKey, out EmergencyCase? cached)) - return cached; - - var emergency = await _repository.GetByIdAsync(id, cancellationToken); - - if (emergency != null) - { - _cache.Set(cacheKey, emergency, _cacheExpiry); - } - - return emergency; - } - - // Invalidate cache on updates - public async Task UpdateAsync(EmergencyCase entity, CancellationToken cancellationToken = default) - { - await _repository.UpdateAsync(entity, cancellationToken); - _cache.Remove($"emergency-{entity.Id}"); - } -} -``` - ---- - -## Deployment & DevOps - -### 1. Docker Setup -```dockerfile -# Dockerfile for each context -FROM mcr.microsoft.com/dotnet/aspnet:8.0 -WORKDIR /app - -COPY bin/Release/net8.0/publish/ . - -ENTRYPOINT ["dotnet", "SnakeAid.EmergencyRescue.API.dll"] -``` - -### 2. Kubernetes Deployment -```yaml -# k8s/emergency-rescue-deployment.yaml -apiVersion: apps/v1 -kind: Deployment -metadata: - name: emergency-rescue-api -spec: - replicas: 3 - selector: - matchLabels: - app: emergency-rescue-api - template: - metadata: - labels: - app: emergency-rescue-api - spec: - containers: - - name: emergency-rescue-api - image: snakeaid/emergency-rescue-api:latest - ports: - - containerPort: 80 - env: - - name: ConnectionStrings__DefaultConnection - valueFrom: - secretKeyRef: - name: database-secret - key: connection-string -``` - ---- - -**Next**: [Agent Prompt](ddd-migration.prompt.md) \ No newline at end of file diff --git a/docs/02-layers/jenkins/jenkins.introduction.md b/docs/02-layers/jenkins/jenkins.introduction.md deleted file mode 100644 index 33b74885..00000000 --- a/docs/02-layers/jenkins/jenkins.introduction.md +++ /dev/null @@ -1,14 +0,0 @@ -# Jenkins Pipeline for ASP.NET - Introduction - -## What is it? -This Jenkins pipeline automates the build, test, and release process for the SnakeAid ASP.NET Core Backend. It uses Docker to ensure consistent build environments and artifacts. - -## Why use it? -- **Automated Builds**: Every Pull Request (PR) is automatically built to ensure code quality before merging. -- **Docker Integration**: Builds the application into a Docker container, making it ready for deployment on any platform (ZimaOS, VPS, Cloud). -- **Notifications**: Sends status updates (Success/Failure) to Discord, alerting the team immediately. -- **Continuous Delivery**: Merges to `main` branch automatically push a new Docker image image to Docker Hub with version tags. - -## Use Cases -- **PR Checks**: When a developer opens a PR, Jenkins builds the code to verify it compiles and the Docker image can be built. -- **Release Automation**: When code is merged to `main`, a new version is published to Docker Hub (`snakeaid/backend:latest` and `:BUILD_NUMBER`). diff --git a/docs/02-layers/jenkins/jenkins.plan.md b/docs/02-layers/jenkins/jenkins.plan.md deleted file mode 100644 index 63b0b67d..00000000 --- a/docs/02-layers/jenkins/jenkins.plan.md +++ /dev/null @@ -1,30 +0,0 @@ -# Jenkins Pipeline for ASP.NET Core - Implementation Plan - -## Current State -- Existing `Jenkinsfile` is a template for Java (Maven). -- Project is ASP.NET Core (`SnakeAid.Api`). -- No `Dockerfile` exists in the repository. - -## Proposed Changes -1. **Modify `Jenkinsfile`**: - - Replace Maven commands with `dotnet` CLI commands (`restore`, `build`, `test`). - - Update Docker image naming to `snakeaid-backend`. - - Update build stages to reflect ASP.NET Core workflow. - - Keep Discord notification logic but mark credentials as needing update. -2. **Create `Dockerfile`**: - - Create a multi-stage `Dockerfile` for building and running the ASP.NET Core API (`SnakeAid.Api`). -3. **Create Documentation**: - - Follow the structure defined in `docs/README.md`. - - `jenkins.introduction.md`: Overview of the pipeline. - - `jenkins.usageguide.md`: Setup instructions for Jenkins on ZimaOS. - - `jenkins.sourcecode.md`: Explanation of the pipeline code. - -## Files to Modify -- `Jenkinsfile` - -## Files to Create -- `Dockerfile` -- `docs/Jenkins/jenkins.introduction.md` -- `docs/Jenkins/jenkins.plan.md` (this file) -- `docs/Jenkins/jenkins.sourcecode.md` -- `docs/Jenkins/jenkins.usageguide.md` diff --git a/docs/02-layers/jenkins/jenkins.sourcecode.md b/docs/02-layers/jenkins/jenkins.sourcecode.md deleted file mode 100644 index f948547a..00000000 --- a/docs/02-layers/jenkins/jenkins.sourcecode.md +++ /dev/null @@ -1,75 +0,0 @@ -# Jenkins Pipeline - Source Code - -## Jenkinsfile -**Location**: [Jenkinsfile](../../Jenkinsfile) - -This file defines the CI/CD pipeline using Declarative Pipeline syntax. - -```groovy -pipeline { - agent any - - environment { - // ... (Environment variables) - IMAGE = 'snakeaid/backend' - } - - stages { - stage('Checkout') { ... } - - stage('Build Code (PR)') { - when { expression { env.CHANGE_ID != null } } - steps { - script { - docker.build("${IMAGE}:${tag}", "-f Dockerfile .") - } - } - } - - stage('Build & Push Docker (Release)') { - when { - allOf { - branch 'main' - not { changeRequest() } - } - } - steps { - script { - docker.withRegistry(REGISTRY_URL, REGISTRY_CREDENTIAL) { - img.push() - img.push('latest') - } - } - } - } - } -} -``` - -## Dockerfile -**Location**: [Dockerfile](../../Dockerfile) - -Multi-stage file for building ASP.NET Core application. - -```dockerfile -# Build Stage -FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build -WORKDIR /src -COPY ["SnakeAid.Api/SnakeAid.Api.csproj", "SnakeAid.Api/"] -# ... (Copy other projects) -RUN dotnet restore "SnakeAid.Api/SnakeAid.Api.csproj" -COPY . . -WORKDIR "/src/SnakeAid.Api" -RUN dotnet build "SnakeAid.Api.csproj" -c Release -o /app/build - -# Publish Stage -FROM build AS publish -RUN dotnet publish "SnakeAid.Api.csproj" -c Release -o /app/publish /p:UseAppHost=false - -# Final Stage -FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final -WORKDIR /app -COPY --from=publish /app/publish . -EXPOSE 8080 -ENTRYPOINT ["dotnet", "SnakeAid.Api.dll"] -``` diff --git a/docs/02-layers/jenkins/jenkins.usageguide.md b/docs/02-layers/jenkins/jenkins.usageguide.md deleted file mode 100644 index 7c21df38..00000000 --- a/docs/02-layers/jenkins/jenkins.usageguide.md +++ /dev/null @@ -1,64 +0,0 @@ -# Jenkins Pipeline - Usage Guide - -## Prerequisites - -1. **Jenkins Server**: Running on your ZimaOS or other server. -2. **Docker**: Installed on the Jenkins agent (host or node). The pipeline runs `docker build` commands. -3. **Discord Webhook**: A webhook URL for the Discord channel where you want notifications. -4. **Docker Hub Account**: For pushing the images. - -## Setup Instructions - -### 1. Configure Credentials in Jenkins - -Go to **Manage Jenkins** -> **Credentials** -> **System** -> **Global credentials (unrestricted)** -> **Add Credentials**. - -#### A. Docker Hub Credentials -- **Kind**: Username with password -- **Scope**: Global -- **ID**: `docker-hub-credentials` (matches `Jenkinsfile`) -- **Username**: Your Docker Hub username -- **Password**: Your Docker Hub password or Access Token -- **Description**: Docker Hub Credentials - -#### B. Discord Webhook -- **Kind**: Secret text -- **Scope**: Global -- **ID**: `discord_webhook_capstone` (matches `Jenkinsfile`) -- **Secret**: Paste your Discord Webhook URL here (e.g., `https://discord.com/api/webhooks/...`) -- **Description**: Discord Webhook URL - -### 2. Configure Pipeline Project - -1. **New Item**: Click "New Item" on Jenkins Dashboard. -2. **Name**: Enter a name (e.g., `SnakeAid-Backend`). -3. **Type**: Select **Pipeline**. -4. **Configuration**: - - **Definition**: Pipeline from SCM. - - **SCM**: Git. - - **Repository URL**: `https://github.com/YourUser/SnakeAid.Backend.git` (Use your actual URL). - - **Credentials**: Select git credentials if the repo is private. - - **Branch Specifier**: `*/main` or leave empty for all branches (to support PR building with multibranch plugin, typically you use "Multibranch Pipeline" for PRs, but for simple Pipeline, this watches main). - - **Script Path**: `Jenkinsfile` -5. **Save**. - -### 3. Run Pipeline -- Click **Build Now** to trigger manually. -- Or configure **Webhooks** in GitHub Settings to trigger Jenkins on push. - -## Customization - -### Changing Image Name -Edit `Jenkinsfile`: -```groovy -environment { - IMAGE = 'your-username/snakeaid-backend' -} -``` - -### Discord IDs -Update the user IDs in `Jenkinsfile` to mention specific users on Discord: -```groovy -HOANG_DISCORD_ID = '...' -MINH_DISCORD_ID = '...' -``` diff --git a/docs/02-layers/nuget/NuGet_Upgrade_Doc.md b/docs/02-layers/nuget/NuGet_Upgrade_Doc.md deleted file mode 100644 index a3175bcd..00000000 --- a/docs/02-layers/nuget/NuGet_Upgrade_Doc.md +++ /dev/null @@ -1,66 +0,0 @@ -# NuGet Package Upgrade Documentation - -This document lists all NuGet packages defined in `Directory.Packages.props` and their current status regarding updates. The status is determined by checking for newer versions available on NuGet.org. - -## Package Status - -| Package | Status | Current Version | Latest Version | Notes | -|---------|--------|-----------------|----------------|-------| -| AutoBogus | Up to date | 2.13.1 | 2.13.1 | | -| Bogus | Up to date | 35.5.0 | 35.5.0 | | -| CloudinaryDotNet | Up to date | 1.27.4 | 1.27.4 | | -| FirebaseAdmin | Up to date | 3.4.0 | 3.4.0 | | -| MailKit | Up to date | 4.11.0 | 4.11.0 | | -| Mapster | Up to date | 7.4.0 | 7.4.0 | | -| Mapster.DependencyInjection | Up to date | 1.0.1 | 1.0.1 | | -| Microsoft.AspNetCore.Authentication | Up to date | 2.3.0 | 2.3.0 | | -| Microsoft.AspNetCore.Http | Up to date | 2.3.0 | 2.3.0 | | -| Microsoft.AspNetCore.Http.Extensions | Up to date | 2.3.0 | 2.3.0 | | -| Microsoft.EntityFrameworkCore | Up to date | 8.0.11 | 8.0.11 | | -| Microsoft.EntityFrameworkCore.Tools | Up to date | 8.0.11 | 8.0.11 | | -| Microsoft.Extensions.ServiceDiscovery.Yarp | Up to date | 9.4.1 | 9.4.1 | | -| MimeKit | Up to date | 4.11.0 | 4.11.0 | | -| NETCore.MailKit | Up to date | 2.1.0 | 2.1.0 | | -| Npgsql | Up to date | 8.0.7 | 8.0.7 | | -| Npgsql.EntityFrameworkCore.PostgreSQL | Up to date | 8.0.11 | 8.0.11 | | -| Npgsql.EntityFrameworkCore.PostgreSQL.NetTopologySuite | Up to date | 8.0.11 | 8.0.11 | | -| PreMailer.Net | Up to date | 2.6.0 | 2.6.0 | | -| RazorLight | Up to date | 2.3.1 | 2.3.1 | | -| Refit | Up to date | 7.2.22 | 7.2.22 | | -| Refit.HttpClientFactory | Up to date | 7.0.0 | 7.0.0 | | -| Scrutor | Up to date | 7.0.0 | 7.0.0 | Updated from 4.2.2 - compatible with .NET 8 | -| Serilog.Enrichers.Environment | Up to date | 3.0.1 | 3.0.1 | | -| Serilog.Enrichers.Span | Up to date | 3.1.0 | 3.1.0 | | -| Serilog.Sinks.Console | Up to date | 6.1.1 | 6.1.1 | | -| Serilog.Sinks.Grafana.Loki | Up to date | 8.3.2 | 8.3.2 | | -| Serilog.Sinks.SQLite | Up to date | 6.0.0 | 6.0.0 | | -| Serilog.UI | Up to date | 3.2.0 | 3.2.0 | | -| Serilog.UI.SqliteProvider | Up to date | 1.1.0 | 1.1.0 | | -| SQLitePCLRaw.bundle_e_sqlite3 | Up to date | 3.0.2 | 3.0.2 | | -| Swashbuckle.AspNetCore.Annotations | Up to date | 8.0.0 | 8.0.0 | | -| Swashbuckle.AspNetCore.Filters | Up to date | 8.0.0 | 8.0.0 | | -| VietQRHelper | Up to date | 1.0.2 | 1.0.2 | | -| Yarp.ReverseProxy | Up to date | 2.3.0 | 2.3.0 | | -| Carter | possible highest net8 | 8.0.0 | 10.0.0 | Major version update (8 → 10). Check compatibility. | -| Microsoft.AspNetCore.Authentication.JwtBearer | possible highest net8 | 8.0.23 | 10.0.2 | Major version update (8 → 10). Check compatibility. | -| Microsoft.EntityFrameworkCore.Design | possible highest net8 | 8.0.23 | 10.0.2 | Major version update (8 → 10). Check compatibility. | -| Serilog.AspNetCore | possible highest net8 | 8.0.3 | 10.0.0 | | -| Serilog.Settings.Configuration | possible highest net8 | 8.0.4 | 10.0.0 | | -| Swashbuckle.AspNetCore | possible highest net8 | 8.1.4 | 10.1.0 | Major version update (8 → 10). Check compatibility. | - -## Upgrade Recommendations - -- **Outdated Packages**: 6 packages are outdated. -- **Major Updates**: Several packages have major version updates (e.g., Carter 8→10, Serilog packages 8→10). These may introduce breaking changes. Review release notes and test thoroughly before upgrading. - -## How to Upgrade - -1. Update the version in `Directory.Packages.props`. -2. Run `dotnet restore` to update the packages. -3. Build and test the application. -4. For major updates, review breaking changes in the package's changelog. - -## Last Checked - -January 23, 2026 -d:\SourceCode\Snake_AID\SnakeAid.Backend\NuGet_Upgrade_Doc.md \ No newline at end of file diff --git a/docs/02-layers/nuget/Nuget Toys Tracker.md b/docs/02-layers/nuget/Nuget Toys Tracker.md deleted file mode 100644 index 4411cf39..00000000 --- a/docs/02-layers/nuget/Nuget Toys Tracker.md +++ /dev/null @@ -1,63 +0,0 @@ -# Nuget Toys Tracker - -Track implementation and configuration work for packages already referenced in CPM and csproj files. -Goal: mirror EzyFix behavior and config from `D:\SourceCode\EZYFIX\EzyFix.aspnet`. - -## Legend -- [ ] Not started -- [~] In progress -- [x] Done - -## Data seeding (Repository) -- [ ] AutoBogus/Bogus: port seeding entrypoint and runner pipeline (EzyFixSeeder -> SeedRunner) ([EzyFixSeeder](D:\SourceCode\EZYFIX\EzyFix.aspnet\EzyFix.Data\Seed\EzyFixSeeder.cs)) ([SeedRunner](D:\SourceCode\EZYFIX\EzyFix.aspnet\EzyFix.Data\Seed\SeederCore\SeedRunner.cs)) -- [ ] AutoBogus/Bogus: implement SeedOptions validation, environment guard, and skip-if-users-exist logic ([SeedRunner](D:\SourceCode\EZYFIX\EzyFix.aspnet\EzyFix.Data\Seed\SeederCore\SeedRunner.cs)) -- [ ] AutoBogus/Bogus: configure AutoFaker depth + deterministic seed + locale Faker ([FakerConfig](D:\SourceCode\EZYFIX\EzyFix.aspnet\EzyFix.Data\Seed\SeederCore\FakerConfig.cs)) -- [ ] AutoBogus/Bogus: match module order (RoleSeeder -> AdminSeeder -> SupporterSeeder -> DefaultAccountSeeder -> CatalogSeeder -> VoucherSeeder -> UserSeeder -> TechnicianSeeder -> AppointmentSeeder -> PaymentSeeder -> VoucherUsageSeeder -> WalletTransactionSeeder -> ReviewSeeder -> DisputeSeeder -> PayoutSeeder) ([SeedRunner](D:\SourceCode\EZYFIX\EzyFix.aspnet\EzyFix.Data\Seed\SeederCore\SeedRunner.cs)) -- [ ] AutoBogus/Bogus: add API flags `--seed` and `--seed-force` with default SeedOptions values (RandomSeed=2025, Locale=en, counts/ratios) ([Program](D:\SourceCode\EZYFIX\EzyFix.aspnet\EzyFix.API\Program.cs)) - -## Minimal API (API) -- [ ] Carter: register `services.AddCarter()` ([Program](D:\SourceCode\EZYFIX\EzyFix.aspnet\EzyFix.API\Program.cs)) -- [ ] Carter: map `app.MapCarter()` ([Program](D:\SourceCode\EZYFIX\EzyFix.aspnet\EzyFix.API\Program.cs)) -- [ ] Carter: implement `CarterModule` routes (example) ([PasswordToolsModule](D:\SourceCode\EZYFIX\EzyFix.aspnet\EzyFix.API\Modules\PasswordToolsModule.cs)) - -## Uploads (Service) -- [ ] CloudinaryDotNet: add Cloudinary settings (CloudName, ApiKey, ApiSecret) in appsettings ([appsettings.json](D:\SourceCode\EZYFIX\EzyFix.aspnet\EzyFix.API\appsettings.json)) -- [ ] CloudinaryDotNet: validate settings on startup and build Cloudinary client ([CloudinaryService](D:\SourceCode\EZYFIX\EzyFix.aspnet\EzyFix.Service\Services\Implements\CloudinaryService.cs)) -- [ ] CloudinaryDotNet: implement upload logic (extension + size limits, SecureUrl checks, `UserId` claim) ([CloudinaryService](D:\SourceCode\EZYFIX\EzyFix.aspnet\EzyFix.Service\Services\Implements\CloudinaryService.cs)) -- [ ] CloudinaryDotNet: register service via Scrutor scanning in API ([Program](D:\SourceCode\EZYFIX\EzyFix.aspnet\EzyFix.API\Program.cs)) - -## HTTP client wrapper (Service) -- [ ] Refit/Refit.HttpClientFactory: add LocationIq config (BaseUrl, ApiKeys, timeouts, Orchestrator) in appsettings ([appsettings.json](D:\SourceCode\EZYFIX\EzyFix.aspnet\EzyFix.API\appsettings.json)) -- [ ] Refit/Refit.HttpClientFactory: define API interface(s) with Refit attributes ([LocationIqApi](D:\SourceCode\EZYFIX\EzyFix.aspnet\EzyFix.Service\Services\LocationIq\Api\LocationIqApi.cs)) -- [ ] Refit/Refit.HttpClientFactory: register AddRefitClient with base URL, timeout, headers ([LocationIqServiceCollectionExtensions](D:\SourceCode\EZYFIX\EzyFix.aspnet\EzyFix.Service\Extension\LocationIqServiceCollectionExtensions.cs)) -- [ ] Refit/Refit.HttpClientFactory: call `services.AddLocationIqOrchestrator(configuration)` ([Program](D:\SourceCode\EZYFIX\EzyFix.aspnet\EzyFix.API\Program.cs)) - -## Email (Service) -- [ ] MailKit/MimeKit/NETCore.MailKit: add EmailSettings config and provider selection (Resend/SMTP) in appsettings ([appsettings.json](D:\SourceCode\EZYFIX\EzyFix.aspnet\EzyFix.API\appsettings.json)) -- [ ] MailKit/MimeKit/NETCore.MailKit: implement provider selection and fallback strategy ([EmailProviderService](D:\SourceCode\EZYFIX\EzyFix.aspnet\EzyFix.Service\Services\Implements\Email\Providers\EmailProviderService.cs)) -- [ ] NETCore.MailKit: implement SMTP sender (EzyFix uses System.Net.Mail) ([SmtpEmailSender](D:\SourceCode\EZYFIX\EzyFix.aspnet\EzyFix.Service\Services\Implements\Email\Providers\SmtpEmailSender.cs)) -- [ ] MailKit/MimeKit: implement Resend sender for HTTP provider fallback ([ResendEmailSender](D:\SourceCode\EZYFIX\EzyFix.aspnet\EzyFix.Service\Services\Implements\Email\Providers\ResendEmailSender.cs)) -- [ ] RazorLight: render templates from file system with memory cache ([EmailTemplateService](D:\SourceCode\EZYFIX\EzyFix.aspnet\EzyFix.Service\Services\Implements\Email\EmailTemplateService.cs)) -- [ ] PreMailer.Net: inline CSS for HTML templates before send ([EmailTemplateService](D:\SourceCode\EZYFIX\EzyFix.aspnet\EzyFix.Service\Services\Implements\Email\EmailTemplateService.cs)) -- [ ] RazorLight: ensure templates are copied to output for runtime rendering ([EzyFix.BLL.csproj](D:\SourceCode\EZYFIX\EzyFix.aspnet\EzyFix.Service\EzyFix.BLL.csproj)) -- [ ] NETCore.MailKit: register email services via Scrutor and add `services.AddHttpClient()` ([Program](D:\SourceCode\EZYFIX\EzyFix.aspnet\EzyFix.API\Program.cs)) -- [ ] MailKit/MimeKit/NETCore.MailKit: note EzyFix references packages but SMTP uses System.Net.Mail (no MailKit usage) ([SmtpEmailSender](D:\SourceCode\EZYFIX\EzyFix.aspnet\EzyFix.Service\Services\Implements\Email\Providers\SmtpEmailSender.cs)) - -## Payment QR (Service) -- [ ] VietQRHelper: implement bank directory cache from `BankApp.BanksObject` ([BankDirectoryService](D:\SourceCode\EZYFIX\EzyFix.aspnet\EzyFix.Service\Services\Implements\BankDirectoryService.cs)) -- [ ] VietQRHelper: implement adapter using `QRPay.InitVietQR(...).Build()` with fallback payload ([VietQrAdapter](D:\SourceCode\EZYFIX\EzyFix.aspnet\EzyFix.Service\Services\Implements\VietQrAdapter.cs)) -- [ ] VietQRHelper: optional QR image base64 generation uses QRCoder (not in CPM) ([VietQrAdapter](D:\SourceCode\EZYFIX\EzyFix.aspnet\EzyFix.Service\Services\Implements\VietQrAdapter.cs)) - -## Logging (API) -- [x] Serilog.Settings.Configuration: add Serilog section in appsettings (Using/MinimumLevel/Enrich/WriteTo) ([appsettings.json](D:\SourceCode\EZYFIX\EzyFix.aspnet\EzyFix.API\appsettings.json)) -- [x] Serilog.AspNetCore + Serilog.Settings.Configuration: configure bootstrap logger + `builder.Host.UseSerilog(...ReadFrom.Configuration...)` ([Program](D:\SourceCode\EZYFIX\EzyFix.aspnet\EzyFix.API\Program.cs)) -- [x] SQLitePCLRaw.bundle_e_sqlite3: call `SQLitePCL.Batteries_V2.Init()` before Serilog uses SQLite ([Program](D:\SourceCode\EZYFIX\EzyFix.aspnet\EzyFix.API\Program.cs)) -- [x] Serilog.Sinks.SQLite: set sqlite log path `logs/logs.db` and override `Serilog:WriteTo:1:Args:sqliteDbPath` ([Program](D:\SourceCode\EZYFIX\EzyFix.aspnet\EzyFix.API\Program.cs)) -- [x] Serilog.Sinks.Console: configure console output template in Serilog config ([Program](D:\SourceCode\EZYFIX\EzyFix.aspnet\EzyFix.API\Program.cs)) -- [x] Serilog.Enrichers.Environment + Serilog.Enrichers.Span: configure enrichers in Serilog config (WithMachineName/WithEnvironmentName/WithSpan) ([Program](D:\SourceCode\EZYFIX\EzyFix.aspnet\EzyFix.API\Program.cs)) -- [x] Serilog.UI + Serilog.UI.SqliteProvider: register Serilog UI and map `/logs` ([Program](D:\SourceCode\EZYFIX\EzyFix.aspnet\EzyFix.API\Program.cs)) -- [ ] (optional) Serilog.Sinks.SQLite + Serilog.UI: run `EnsureSqliteLogSchema` to add/backfill `Message` column when reusing an existing `logs.db` or Serilog.UI errors; skip for fresh log DB ([EnsureSqliteLogSchema](D:\SourceCode\EZYFIX\EzyFix.aspnet\EzyFix.API\Program.cs)) -- [x] Serilog.Sinks.Grafana.Loki: add Loki sink config and labels (not in EzyFix config) ([Program](D:\SourceCode\EZYFIX\EzyFix.aspnet\EzyFix.API\Program.cs)) - -## Dependency scanning (API) -- [ ] Scrutor: mirror scan patterns for services, unit of work, utils, and email services ([Program](D:\SourceCode\EZYFIX\EzyFix.aspnet\EzyFix.API\Program.cs)) diff --git a/docs/02-layers/snake-library/Yolo_data.yaml b/docs/02-layers/snake-library/Yolo_data.yaml deleted file mode 100644 index eff92f23..00000000 --- a/docs/02-layers/snake-library/Yolo_data.yaml +++ /dev/null @@ -1,6 +0,0 @@ -train: ../train/images -val: ../valid/images -test: ../test/images - -nc: 22 -names: ['cap_nia_bac', 'cap_nia_nam', 'cap_nong', 'ho_mang_chua', 'ho_mang_xiem', 'khiem_vach', 'luc_cuom', 'luc_nua', 'luc_xanh', 'luc_xanh_duoi_do', 'ran_cuom', 'ran_dai_lon', 'ran_hoa_can_van_dom', 'ran_hoa_co_do', 'ran_rao', 'ran_rao_trau', 'ran_ri_ca', 'ran_roi', 'ran_sai_co', 'ran_soc_dua', 'ran_soc_go', 'ran_trun'] \ No newline at end of file diff --git a/docs/DOCSIFY.md b/docs/DOCSIFY.md deleted file mode 100644 index 0bc22b22..00000000 --- a/docs/DOCSIFY.md +++ /dev/null @@ -1,148 +0,0 @@ -# Docsify Documentation Site - -This folder contains the SnakeAid Backend documentation powered by [Docsify](https://docsify.js.org/). - -## Quick Start - -### Option 1: Using Python (Recommended) - -```bash -# Navigate to docs folder -cd docs - -# Start a simple HTTP server -python -m http.server 3000 - -# Open browser to http://localhost:3000 -``` - -### Option 2: Using Node.js - -```bash -# Install docsify-cli globally -npm i docsify-cli -g - -# Navigate to docs folder -cd docs - -# Serve the documentation -docsify serve - -# Open browser to http://localhost:3000 -``` - -### Option 3: Using Live Server (VS Code) - -1. Install "Live Server" extension in VS Code -2. Right-click on `docs/index.html` -3. Select "Open with Live Server" - -## File Structure - -``` -docs/ -├── index.html # Docsify configuration -├── _sidebar.md # Sidebar navigation -├── HOME.md # Home page content -├── README.md # Documentation organization guide -│ -├── ASP Identity/ # ASP.NET Identity documentation -│ ├── aspnet-identity.introduction.md -│ ├── aspnet-identity.plan.md -│ ├── aspnet-identity.prompt.md -│ ├── aspnet-identity.sourcecode.md -│ └── aspnet-identity.usageguilde.md -│ -└── NuGet/ # NuGet documentation - └── NuGet_Upgrade_Doc.md -``` - -## Features - -- ✅ Full-text search -- ✅ Syntax highlighting (C#, JSON, SQL, JS, TS) -- ✅ Mermaid diagram support -- ✅ Copy code button -- ✅ Responsive design -- ✅ Pagination -- ✅ Zoom images - -## Adding New Documentation - -1. Create a new folder for your feature/technology -2. Create 5 documentation files: - - `*.introduction.md` - - `*.plan.md` - - `*.prompt.md` - - `*.sourcecode.md` - - `*.usageguilde.md` -3. Update `_sidebar.md` to add navigation links -4. Refresh the browser to see changes - -## Customization - -### Changing Theme - -Edit `index.html` and change the CSS link: - -```html - - -``` - -### Changing Colors - -Edit the CSS variables in `index.html`: - -```css -:root { - --theme-color: #4caf50; /* Primary color */ - --theme-color-dark: #388e3c; /* Dark variant */ -} -``` - -## Deployment - -### GitHub Pages - -1. Push `docs` folder to GitHub -2. Go to repository Settings → Pages -3. Select branch and `/docs` folder -4. Save and wait for deployment - -### Netlify - -1. Create `netlify.toml` in project root: - ```toml - [build] - publish = "docs" - ``` -2. Connect repository to Netlify -3. Deploy - -## Troubleshooting - -### Mermaid diagrams not rendering - -- Make sure you're using 4 backticks for code blocks containing mermaid -- Check browser console for errors - -### Search not working - -- Clear browser cache -- Make sure `search.min.js` plugin is loaded - -### Sidebar not showing - -- Check `_sidebar.md` file exists -- Verify `loadSidebar: true` in `index.html` - -## Resources - -- [Docsify Documentation](https://docsify.js.org/) -- [Docsify Plugins](https://docsify.js.org/#/plugins) -- [Mermaid Documentation](https://mermaid.js.org/) - ---- - -**Last Updated**: 2026-01-24 diff --git a/docs/HOME.md b/docs/HOME.md deleted file mode 100644 index 11b8a3b5..00000000 --- a/docs/HOME.md +++ /dev/null @@ -1,82 +0,0 @@ -# SnakeAid Backend Documentation - -> Comprehensive documentation for SnakeAid Backend API - -## Welcome! 🐍 - -This documentation site provides detailed information about the SnakeAid Backend implementation, including: - -- **ASP.NET Identity**: Authentication and authorization system -- **API Endpoints**: RESTful API documentation -- **Database Schema**: Entity models and relationships -- **Development Guide**: How to contribute and maintain the codebase - -## Quick Links - -- [ASP.NET Identity Introduction](ASP%20Identity/aspnet-identity.introduction.md) -- [Source Code Reference](ASP%20Identity/aspnet-identity.sourcecode.md) -- [API Usage Guide](ASP%20Identity/aspnet-identity.usageguilde.md) -- [Documentation Organization Guide](README.md) - -## Documentation Structure - -Each feature/technology has **5 types of documentation**: - -| File Type | Purpose | Audience | -|-----------|---------|----------| -| `*.introduction.md` | Overview and requirements | PM, Tech Lead, New Developers | -| `*.plan.md` | Implementation plan | Developers, Tech Lead | -| `*.prompt.md` | AI Agent instructions | AI Agents | -| `*.sourcecode.md` | Code reference (function-level) | AI Agents, Developers | -| `*.usageguilde.md` | API usage examples | Frontend Developers | - -## Features - -This documentation site includes: - -- 🔍 **Full-text search** - Search across all documentation -- 📱 **Responsive design** - Works on mobile and desktop -- 🎨 **Syntax highlighting** - C#, JSON, SQL, JavaScript, TypeScript -- 📊 **Mermaid diagrams** - Flow charts and diagrams -- 📋 **Copy code** - One-click code copying -- 🔗 **Deep linking** - Share links to specific sections - -## Getting Started - -### For Backend Developers -1. Read [Documentation Organization Guide](README.md) -2. Check [ASP.NET Identity Source Code](ASP%20Identity/aspnet-identity.sourcecode.md) -3. Follow the implementation patterns - -### For Frontend Developers -1. Read [API Usage Guide](ASP%20Identity/aspnet-identity.usageguilde.md) -2. Check authentication flow examples -3. Integrate with your frontend app - -### For AI Agents -1. Read `*.prompt.md` for implementation instructions -2. Use `*.sourcecode.md` as context (saves tokens!) -3. Generate code following existing patterns - -## Tech Stack - -- **Framework**: ASP.NET Core 8.0 -- **Database**: PostgreSQL -- **ORM**: Entity Framework Core -- **Authentication**: ASP.NET Core Identity + JWT -- **External Auth**: Google Sign-In - -## Contributing - -When adding new features: - -1. Create folder: `docs/[Feature Name]/` -2. Create 5 documentation files following the naming convention -3. Update `_sidebar.md` to add navigation links -4. Follow the documentation guidelines in [README.md](README.md) - ---- - -**Project**: SnakeAid Backend -**Last Updated**: 2026-01-24 -**Maintained By**: Backend Team diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index 1360c780..00000000 --- a/docs/README.md +++ /dev/null @@ -1,303 +0,0 @@ -# Documentation Organization Guide - -## Folder Structure - -Tài liệu được tổ chức theo **2 chiều**: - -| Dimension | Folder | Mô tả | -|-----------|--------|-------| -| **Vertical** | `01-flows/` | User journeys - theo flow nghiệp vụ | -| **Horizontal** | `02-layers/` | System layers - lát cắt hạ tầng | - -``` -docs/ -├── README.md # File này -├── _sidebar.md # Docsify navigation -│ -├── 01-flows/ # 📱 VERTICAL: User Flows -│ ├── _API_Plan.md # Roadmap tổng hợp tất cả endpoints -│ │ -│ ├── P1-emergency/ # Flow P1: Cứu hộ khẩn cấp -│ │ ├── S1-identified/ # SubFlow: Nhận diện được rắn -│ │ │ └── _flow.md # Các bước trong subflow -│ │ └── S2-not-identified/ # SubFlow: Không nhận điện -│ │ -│ └── P2-catching/ # Flow P2: Bắt rắn -│ ├── S1-single-snake/ # SubFlow: Bắt 1 con -│ └── ... -│ -└── 02-layers/ # 🔧 HORIZONTAL: System Layers - ├── aspnet identity/ # Authentication (ASP.NET Identity) - ├── cloudinary/ # Media storage (Cloudinary) - ├── ai/ # AI services (SnakeAI) - ├── docker/ # Docker containerization - ├── jenkins/ # CI/CD pipeline - ├── domain driven design/ # Architecture patterns (DDD) - └── nuget/ # Package management (NuGet) -``` - ---- - -## Hierarchy Explanation - -### Flows (Vertical - 01-flows/) - -Tổ chức theo **user journey**, xuyên qua nhiều layer của hệ thống. - -``` -Flow (P1, P2...) # Feature chính (Cứu hộ, Bắt rắn...) - └── SubFlow (S1, S2...) # Nhánh con trong flow - └── Screen/Step # Màn hình / bước cụ thể - └── Endpoint # API endpoint (nếu có) -``` - -**Naming convention:** -| Level | Format | Ví dụ | -|-------|--------|-------| -| Flow | `P{n}-{name}/` | `P1-emergency/` | -| SubFlow | `S{n}-{name}/` | `S1-identified/` | -| Flow overview | `_flow.md` | Liệt kê tất cả screens | - -### Layers (Horizontal - 02-layers/) - -Tổ chức theo **system infrastructure**, phục vụ xuyên suốt nhiều flows. - -| Layer | Mục đích | Ví dụ | -|-------|----------|-------| -| `auth/` | Authentication & Authorization | ASP Identity, JWT | -| `media/` | File & Image storage | Cloudinary | -| `ai/` | AI/ML services | SnakeAI YOLO model | -| `devops/` | CI/CD, Containerization | Docker, Jenkins | -| `architecture/` | Design patterns | Domain Driven Design | -| `packages/` | Dependency management | NuGet | - ---- - -## File Naming Convention - -Mỗi hạng mục (trong cả flows và layers) có **5 loại file** theo quy ước: - -### 1. `*.introduction.md` - Giới thiệu - -**Mục đích**: Giới thiệu chức năng hoặc công nghệ -**Nguồn**: Thường lấy từ SRS (Software Requirements Specification) -**Độ chi tiết**: High-level overview -**Người đọc**: PM, Tech Lead, Developer mới vào dự án - -**Nội dung bao gồm**: -- Tổng quan về chức năng/công nghệ -- Lý do sử dụng (Why?) -- Use cases chính -- Lợi ích và trade-offs -- Tham khảo tài liệu gốc - ---- - -### 2. `*.plan.md` - Kế hoạch Implementation - -**Mục đích**: Kế hoạch để implement chức năng vào codebase hiện có -**Thời điểm**: Trước khi bắt đầu coding -**Độ chi tiết**: Medium-level design -**Người đọc**: Developer, Tech Lead - -**Nội dung bao gồm**: -- Phân tích hiện trạng codebase -- Các thay đổi cần thiết (files to modify/create) -- Dependencies cần thêm -- Migration plan (nếu có) -- Risks và mitigation -- Timeline ước tính - ---- - -### 3. `*.prompt.md` - Prompt cho Agent - -**Mục đích**: Prompt để thực hiện thao tác implement -**Thời điểm**: Ngay trước khi implement (sát với hiện trạng codebase) -**Độ chi tiết**: Very detailed, actionable -**Người đọc**: AI Agent (Antigravity, Copilot, etc.) - -**Nội dung bao gồm**: -- Yêu cầu cụ thể từng bước -- Code snippets mẫu -- Configuration settings -- Testing requirements -- Expected output - -**Đặc điểm**: -- Viết dưới dạng instructions/commands -- Bao gồm tất cả context cần thiết -- Có thể copy-paste trực tiếp cho agent - ---- - -### 4. `*.sourcecode.md` - Trạng thái Codebase - -**Mục đích**: Thể hiện trạng thái codebase sau khi implement -**Thời điểm**: Sau khi implement xong -**Độ chi tiết**: Function-level detail (gần nhất với code) -**Người đọc**: AI Agent, Developer maintenance - -**Nội dung bao gồm**: -- Toàn bộ functions/methods với signatures -- Flow chi tiết từng endpoint -- Request/Response models -- Database schema -- Configuration settings -- Code snippets đầy đủ - -**Mục đích chính**: -- ✅ **Làm context cho agent sử dụng sau này** -- ✅ **Không cần crawl lại codebase → tiết kiệm token** -- ✅ **Onboarding developer mới nhanh hơn** - ---- - -### 5. `*.usageguide.md` - Hướng dẫn Sử dụng - -**Mục đích**: Hướng dẫn sử dụng API/chức năng sau khi implement -**Thời điểm**: Sau khi implement và test xong -**Độ chi tiết**: API documentation level -**Người đọc**: **Frontend Developer**, Mobile Developer, QA - -**Nội dung bao gồm**: -- API endpoints với examples -- Request/Response format -- Authentication flow -- Error handling -- Code examples (JavaScript/TypeScript/Dart) -- Postman collection (nếu có) - ---- - -## Workflow Timeline - -```mermaid -graph LR - A[introduction.md] --> B[plan.md] - B --> C[prompt.md] - C --> D[Implementation] - D --> E[sourcecode.md] - E --> F[usageguide.md] - - style A fill:#e1f5ff - style B fill:#fff9c4 - style C fill:#f3e5f5 - style D fill:#c8e6c9 - style E fill:#ffccbc - style F fill:#d1c4e9 -``` - -| Phase | File | Status | Purpose | -|-------|------|--------|---------| -| **Planning** | `introduction.md` | Before coding | Understand requirements | -| **Design** | `plan.md` | Before coding | Design approach | -| **Execution** | `prompt.md` | Before coding | Agent instructions | -| **Coding** | *(actual code)* | During coding | Implementation | -| **Documentation** | `sourcecode.md` | After coding | Code reference | -| **Integration** | `usageguide.md` | After coding | API documentation | - ---- - -## Relationship Between Flows and Layers - -```mermaid -flowchart TB - subgraph flows["📱 USER FLOWS (Vertical)"] - P1["P1-Emergency"] - P2["P2-Catching"] - P3["P3-Consultation"] - end - - subgraph layers["🔧 LAYERS (Horizontal)"] - L1["aspnet identity"] - L2["cloudinary"] - L3["ai"] - L4["docker"] - L5["jenkins"] - L6["domain driven design"] - L7["nuget"] - end - - P1 --> L1 & L2 & L3 - P2 --> L1 & L2 & L3 - P3 --> L1 & L2 - - style flows fill:#e3f2fd,stroke:#1976d2 - style layers fill:#fff3e0,stroke:#f57c00 -``` - -- **Flows** reference **Layers** khi cần sử dụng infrastructure -- **Layers** được implement một lần, phục vụ nhiều flows - ---- - -## Best Practices - -### 1. Keep Files Updated -- ✅ Update `sourcecode.md` whenever code changes significantly -- ✅ Update `usageguide.md` when API contract changes -- ❌ Don't update `prompt.md` after implementation (it's historical) - -### 2. Audience Awareness -- **Backend team**: All files -- **Frontend team**: `introduction.md` + `usageguide.md` -- **AI Agents**: `prompt.md` (before) + `sourcecode.md` (after) -- **New developers**: `introduction.md` + `sourcecode.md` - -### 3. Token Optimization -- `sourcecode.md` should be **detailed enough** to avoid crawling codebase -- Include: - - ✅ Function signatures - - ✅ Flow diagrams - - ✅ Request/Response examples - - ✅ Database schema - - ✅ Configuration settings -- Avoid: - - ❌ Copying entire files verbatim - - ❌ Redundant explanations - - ❌ Outdated information - ---- - -## Creating New Documentation - -### For a new Flow: - -1. Create folder: `01-flows/P{n}-{flow-name}/` -2. Create subflow folders: `S{n}-{subflow-name}/` -3. Create `_flow.md` in each subflow with screen/step details - -### For a new Layer: - -1. Create folder: `02-layers/{layer-name}/` -2. Create 5 files with naming convention: - - `{layer-name}.introduction.md` - - `{layer-name}.plan.md` - - `{layer-name}.prompt.md` - - `{layer-name}.sourcecode.md` - - `{layer-name}.usageguide.md` - ---- - -## Benefits - -### For Developers -- 📚 Clear separation between vertical (flows) and horizontal (layers) -- 🔍 Easy to find information by user journey or by technology -- 🚀 Faster onboarding - -### For AI Agents -- 🤖 `prompt.md`: Clear instructions to implement -- 📖 `sourcecode.md`: Complete context without crawling code -- 💰 **Saves thousands of tokens** per query - -### For Frontend Team -- 📱 `usageguide.md`: Ready-to-use API examples -- 🎯 Follow flow documentation to understand API sequence -- ⚡ Faster integration - ---- - -**Last Updated**: 2026-01-29 -**Maintained By**: Backend Team diff --git a/docs/_sidebar.md b/docs/_sidebar.md deleted file mode 100644 index 57b09d5c..00000000 --- a/docs/_sidebar.md +++ /dev/null @@ -1,53 +0,0 @@ - - -* [Home](HOME.md) - -* **Documentation Guide** - * [How to Organize Docs](README.md) - * [Docsify Configuration](DOCSIFY.md) - -## Flows (Vertical) - * [API Plan Overview](01-flows/API_Plan.md) - * [Test Flow Plan (CSV)](01-flows/Snake%20Backend%20Plans.csv) - * **P1 - Emergency Rescue** - - [S1: Nhận diện được rắn](01-flows/P1-emergency/S1-identified/_flow.md) - - [S2: Không nhận diện được rắn](01-flows/P1-emergency/S2-not-identified/_flow.md) - * **P2 - Snake Catching** - - [S1: Bắt 1 con rắn](01-flows/P2-catching/S1-single-snake/_flow.md) - - [S2: Bắt nhiều rắn (2-5)](01-flows/P2-catching/S2-multiple-snakes/_flow.md) - - [S3: Bắt cả ổ rắn](01-flows/P2-catching/S3-snake-nest/_flow.md) - - [S4: Màn hình chung](01-flows/P2-catching/S4-common/_flow.md) - -## Layers (Horizontal) - * **ASP.NET Identity** - - [Introduction](02-layers/aspnet%20identity/aspnet-identity.introduction.md) - - [Implementation Plan](02-layers/aspnet%20identity/aspnet-identity.plan.md) - - [Agent Prompt](02-layers/aspnet%20identity/aspnet-identity.prompt.md) - - [Source Code Reference](02-layers/aspnet%20identity/aspnet-identity.sourcecode.md) - - [Usage Guide](02-layers/aspnet%20identity/aspnet-identity.usageguilde.md) - * **Cloudinary** - - [Introduction](02-layers/cloudinary/cloudinary.introduction.md) - - [Implementation Plan](02-layers/cloudinary/cloudinary.plan.md) - - [Agent Prompt](02-layers/cloudinary/cloudinary.prompt.md) - - [Source Code Reference](02-layers/cloudinary/cloudinary.sourcecode.md) - - [Usage Guide](02-layers/cloudinary/cloudinary.usageguilde.md) - * **AI Services** - - [Introduction](02-layers/ai/SankeAi.introduction.md) - * **Domain Driven Design** - - [Introduction](02-layers/domain%20driven%20design/ddd-migration.introduction.md) - - [Migration Plan](02-layers/domain%20driven%20design/ddd-migration.plan.md) - - [Source Code](02-layers/domain%20driven%20design/ddd-migration.sourcecode.md) - - [Usage Guide](02-layers/domain%20driven%20design/ddd-migration.usageguide.md) - - [Agent Prompts](02-layers/domain%20driven%20design/ddd-migration.prompt.md) - * **Docker** - - [Introduction](02-layers/docker/docker.introduction.md) - - [Source Code](02-layers/docker/docker.sourcecode.md) - - [Usage Guide](02-layers/docker/docker.usageguide.md) - * **Jenkins** - - [Introduction](02-layers/jenkins/jenkins.introduction.md) - - [Plan](02-layers/jenkins/jenkins.plan.md) - - [Source Code](02-layers/jenkins/jenkins.sourcecode.md) - - [Usage Guide](02-layers/jenkins/jenkins.usageguide.md) - * **NuGet** - - [NuGet Upgrade Doc](02-layers/nuget/NuGet_Upgrade_Doc.md) - - [NuGet Toys Tracker](02-layers/nuget/Nuget%20Toys%20Tracker.md) \ No newline at end of file diff --git a/docs/index.html b/docs/index.html deleted file mode 100644 index 3cf5d309..00000000 --- a/docs/index.html +++ /dev/null @@ -1,400 +0,0 @@ - - - - - SnakeAid Backend Documentation - - - - - - - - - - - -
Loading...
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -