ducnt hai 3 semanas
pai
achega
e0948c9c02

+ 2 - 0
EsimLao/Common/Constant/CommonConstant.cs

@@ -159,6 +159,7 @@ public static class ApiUrlConstant
     // Auth URLs
     public const String RequestOtpUrl = "/apis/auth/request-otp";
     public const String VerifyOtpUrl = "/apis/auth/verify-otp";
+    public const String ResendOtpUrl = "/apis/auth/resend-otp";
 
     // Article URLs
     public const String ArticleCategoryUrl = "/apis/article/category";
@@ -227,6 +228,7 @@ public static class CommonErrorCode
     public const string OtpSendFailed = "-205";
     public const string OtpTooManyRequests = "-206";
     public const string OtpNotFound = "-207";
+    public const string OtpNotRequested = "-208"; // User hasn't requested OTP yet (for resend scenario)
 
     // ============================================
     // USER/CUSTOMER ERRORS (-300 to -399)

+ 4 - 2
EsimLao/Database/Database/Config.cs

@@ -9,9 +9,11 @@ public partial class Config
 
     public string Name { get; set; } = null!;
 
-    public string ValueLocal { get; set; } = null!;
+    public string? ValueLocal { get; set; }  // Lao (lo)
 
-    public string ValueGlobal { get; set; } = null!;
+    public string? ValueGlobal { get; set; } // English (en)
+
+    public string? Value { get; set; }       // Vietnamese (vi)
 
     public string Type { get; set; } = null!;
 }

+ 16 - 0
EsimLao/Database/Database/ModelContext.cs

@@ -1,6 +1,7 @@
 using System;
 using System.Collections.Generic;
 using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
 
 namespace Database.Database;
 
@@ -67,12 +68,23 @@ public partial class ModelContext : DbContext
 
     public virtual DbSet<WsUser> WsUsers { get; set; }
 
+    // Oracle doesn't have boolean type - configure global conversion from bool to NUMBER(1)
+    protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
+    {
+        configurationBuilder.Properties<bool>()
+            .HaveConversion<BoolToZeroOneConverter<int>>();
+        
+        configurationBuilder.Properties<bool?>()
+            .HaveConversion<BoolToZeroOneConverter<int?>>();
+    }
+
     protected override void OnModelCreating(ModelBuilder modelBuilder)
     {
         modelBuilder
             .HasDefaultSchema("LAOS_ESIM")
             .UseCollation("USING_NLS_COMP");
 
+
         modelBuilder.Entity<AdminUser>(entity =>
         {
             entity.HasKey(e => e.Id).HasName("ADMIN_USER_PK");
@@ -523,6 +535,10 @@ public partial class ModelContext : DbContext
                 .HasMaxLength(1000)
                 .IsUnicode(false)
                 .HasColumnName("VALUE_LOCAL");
+            entity.Property(e => e.Value)
+                .HasMaxLength(1000)
+                .IsUnicode(false)
+                .HasColumnName("VALUE");
         });
 
         modelBuilder.Entity<ContactForm>(entity =>

+ 28 - 28
EsimLao/Esim.Apis/Business/Article/ArticleBusinessImpl.cs

@@ -46,7 +46,7 @@ namespace Esim.Apis.Business
 
                 // Query categories
                 var query = dbContext.ArticleCategories
-                    .Where(c => c.Status == true);
+                    .Where(c => c.Status.HasValue && c.Status.Value);
 
                 // Filter by parent category
                 if (request.parentId.HasValue)
@@ -71,13 +71,13 @@ namespace Esim.Apis.Business
                     .Select(c => new
                     {
                         c.Id,
-                        categoryName = lang == "en"
-                            ? (c.CategoryNameEn ?? c.CategoryName)
-                            : (c.CategoryNameLo ?? c.CategoryName),
+                        categoryName = lang == "en" ? (c.CategoryNameEn ?? c.CategoryName)
+                                     : lang == "vi" ? c.CategoryName
+                                     : (c.CategoryNameLo ?? c.CategoryName),
                         c.CategorySlug,
-                        description = lang == "en"
-                            ? (c.DescriptionEn ?? c.Description)
-                            : (c.DescriptionLo ?? c.Description),
+                        description = lang == "en" ? (c.DescriptionEn ?? c.Description)
+                                    : lang == "vi" ? c.Description
+                                    : (c.DescriptionLo ?? c.Description),
                         c.IconUrl,
                         c.ParentId,
                         c.DisplayOrder
@@ -133,7 +133,7 @@ namespace Esim.Apis.Business
 
                 // Query articles list
                 var query = dbContext.Articles
-                    .Where(a => a.Status == true);
+                    .Where(a => a.Status.HasValue && a.Status.Value);
 
                 // Filter by category
                 if (request.categoryId.HasValue)
@@ -144,7 +144,7 @@ namespace Esim.Apis.Business
                 // Filter by featured
                 if (request.isFeatured.HasValue && request.isFeatured.Value)
                 {
-                    query = query.Where(a => a.IsFeatured == true);
+                    query = query.Where(a => a.IsFeatured.HasValue && a.IsFeatured.Value);
                 }
 
                 // Get total count for pagination
@@ -161,13 +161,13 @@ namespace Esim.Apis.Business
                     .Select(a => new
                     {
                         a.Id,
-                        title = lang == "en"
-                            ? (a.TitleEn ?? a.Title)
-                            : (a.TitleLo ?? a.Title),
+                        title = lang == "en" ? (a.TitleEn ?? a.Title)
+                              : lang == "vi" ? a.Title
+                              : (a.TitleLo ?? a.Title),
                         a.Slug,
-                        summary = lang == "en"
-                            ? (a.SummaryEn ?? a.Summary)
-                            : (a.SummaryLo ?? a.Summary),
+                        summary = lang == "en" ? (a.SummaryEn ?? a.Summary)
+                                : lang == "vi" ? a.Summary
+                                : (a.SummaryLo ?? a.Summary),
                         a.ThumbnailUrl,
                         a.CategoryId,
                         a.ViewCount,
@@ -236,7 +236,7 @@ namespace Esim.Apis.Business
                 string lang = CommonLogic.GetLanguage(httpRequest, request.lang);
 
                 // Query by ID or slug
-                var query = dbContext.Articles.Where(a => a.Status == true);
+                var query = dbContext.Articles.Where(a => a.Status.HasValue && a.Status.Value);
 
                 if (request.id.HasValue)
                 {
@@ -251,21 +251,21 @@ namespace Esim.Apis.Business
                     .Select(a => new
                     {
                         a.Id,
-                        title = lang == "en"
-                            ? (a.TitleEn ?? a.Title)
-                            : (a.TitleLo ?? a.Title),
+                        title = lang == "en" ? (a.TitleEn ?? a.Title)
+                              : lang == "vi" ? a.Title
+                              : (a.TitleLo ?? a.Title),
                         a.Slug,
-                        summary = lang == "en"
-                            ? (a.SummaryEn ?? a.Summary)
-                            : (a.SummaryLo ?? a.Summary),
-                        content = lang == "en"
-                            ? (a.ContentEn ?? a.Content)
-                            : (a.ContentLo ?? a.Content),
+                        summary = lang == "en" ? (a.SummaryEn ?? a.Summary)
+                                : lang == "vi" ? a.Summary
+                                : (a.SummaryLo ?? a.Summary),
+                        content = lang == "en" ? (a.ContentEn ?? a.Content)
+                                : lang == "vi" ? a.Content
+                                : (a.ContentLo ?? a.Content),
                         a.ThumbnailUrl,
                         a.CoverImageUrl,
-                        metaDescription = lang == "en"
-                            ? (a.MetaDescriptionEn ?? a.MetaDescription)
-                            : (a.MetaDescriptionLo ?? a.MetaDescription),
+                        metaDescription = lang == "en" ? (a.MetaDescriptionEn ?? a.MetaDescription)
+                                        : lang == "vi" ? a.MetaDescription
+                                        : (a.MetaDescriptionLo ?? a.MetaDescription),
                         a.MetaKeywords,
                         a.CategoryId,
                         a.ViewCount,

+ 46 - 46
EsimLao/Esim.Apis/Business/Content/ContentBusinessImpl.cs

@@ -38,7 +38,7 @@ namespace Esim.Apis.Business
                 int pageSize = request.pageSize <= 0 ? 10 : request.pageSize;
 
                 var query = dbContext.Banners
-                    .Where(b => b.Status == true)
+                    .Where(b => b.Status.HasValue && b.Status.Value)
                     .Where(b => b.StartDate == null || b.StartDate <= DateTime.Now)
                     .Where(b => b.EndDate == null || b.EndDate >= DateTime.Now);
 
@@ -58,12 +58,12 @@ namespace Esim.Apis.Business
                     .Select(b => new
                     {
                         b.Id,
-                        title = lang == "en"
-                            ? (b.TitleEn ?? b.Title)
-                            : (b.TitleLo ?? b.Title),
-                        subtitle = lang == "en"
-                            ? (b.SubtitleEn ?? b.Subtitle)
-                            : (b.SubtitleLo ?? b.Subtitle),
+                        title = lang == "en" ? (b.TitleEn ?? b.Title)
+                              : lang == "vi" ? b.Title
+                              : (b.TitleLo ?? b.Title),
+                        subtitle = lang == "en" ? (b.SubtitleEn ?? b.Subtitle)
+                                 : lang == "vi" ? b.Subtitle
+                                 : (b.SubtitleLo ?? b.Subtitle),
                         b.ImageUrl,
                         b.ImageMobileUrl,
                         b.LinkUrl,
@@ -104,11 +104,11 @@ namespace Esim.Apis.Business
                 int pageSize = request.pageSize <= 0 ? 10 : request.pageSize;
 
                 var query = dbContext.CustomerReviews
-                    .Where(r => r.Status == true);
+                    .Where(r => r.Status.HasValue && r.Status.Value);
 
                 if (request.isFeatured.HasValue && request.isFeatured.Value)
                 {
-                    query = query.Where(r => r.IsFeatured == true);
+                    query = query.Where(r => r.IsFeatured.HasValue && r.IsFeatured.Value);
                 }
 
                 int totalCount = query.Count();
@@ -125,12 +125,12 @@ namespace Esim.Apis.Business
                         r.CustomerName,
                         r.AvatarUrl,
                         r.Rating,
-                        reviewContent = lang == "en"
-                            ? (r.ReviewContentEn ?? r.ReviewContent)
-                            : (r.ReviewContentLo ?? r.ReviewContent),
-                        destination = lang == "en"
-                            ? (r.DestinationEn ?? r.Destination)
-                            : (r.DestinationLo ?? r.Destination),
+                        reviewContent = lang == "en" ? (r.ReviewContentEn ?? r.ReviewContent)
+                                      : lang == "vi" ? r.ReviewContent
+                                      : (r.ReviewContentLo ?? r.ReviewContent),
+                        destination = lang == "en" ? (r.DestinationEn ?? r.Destination)
+                                    : lang == "vi" ? r.Destination
+                                    : (r.DestinationLo ?? r.Destination),
                         r.IsFeatured,
                         r.CreatedDate
                     })
@@ -210,7 +210,7 @@ namespace Esim.Apis.Business
                 int pageSize = request.pageSize <= 0 ? 10 : request.pageSize;
 
                 var query = dbContext.FaqCategories
-                    .Where(c => c.Status == true);
+                    .Where(c => c.Status.HasValue && c.Status.Value);
 
                 int totalCount = query.Count();
                 int totalPages = (int)Math.Ceiling((double)totalCount / pageSize);
@@ -223,13 +223,13 @@ namespace Esim.Apis.Business
                     .Select(c => new
                     {
                         c.Id,
-                        categoryName = lang == "en"
-                            ? (c.CategoryNameEn ?? c.CategoryName)
-                            : (c.CategoryNameLo ?? c.CategoryName),
+                        categoryName = lang == "en" ? (c.CategoryNameEn ?? c.CategoryName)
+                                     : lang == "vi" ? c.CategoryName
+                                     : (c.CategoryNameLo ?? c.CategoryName),
                         c.CategorySlug,
-                        description = lang == "en"
-                            ? (c.DescriptionEn ?? c.Description)
-                            : (c.DescriptionLo ?? c.Description),
+                        description = lang == "en" ? (c.DescriptionEn ?? c.Description)
+                                    : lang == "vi" ? c.Description
+                                    : (c.DescriptionLo ?? c.Description),
                         c.IconUrl,
                         c.DisplayOrder
                     })
@@ -266,7 +266,7 @@ namespace Esim.Apis.Business
                 int pageSize = request.pageSize <= 0 ? 10 : request.pageSize;
 
                 var query = dbContext.Faqs
-                    .Where(f => f.Status == true);
+                    .Where(f => f.Status.HasValue && f.Status.Value);
 
                 if (request.categoryId.HasValue)
                 {
@@ -275,7 +275,7 @@ namespace Esim.Apis.Business
 
                 if (request.isFeatured.HasValue && request.isFeatured.Value)
                 {
-                    query = query.Where(f => f.IsFeatured == true);
+                    query = query.Where(f => f.IsFeatured.HasValue && f.IsFeatured.Value);
                 }
 
                 int totalCount = query.Count();
@@ -289,12 +289,12 @@ namespace Esim.Apis.Business
                     .Select(f => new
                     {
                         f.Id,
-                        question = lang == "en"
-                            ? (f.QuestionEn ?? f.Question)
-                            : (f.QuestionLo ?? f.Question),
-                        answer = lang == "en"
-                            ? (f.AnswerEn ?? f.Answer)
-                            : (f.AnswerLo ?? f.Answer),
+                        question = lang == "en" ? (f.QuestionEn ?? f.Question)
+                                 : lang == "vi" ? f.Question
+                                 : (f.QuestionLo ?? f.Question),
+                        answer = lang == "en" ? (f.AnswerEn ?? f.Answer)
+                               : lang == "vi" ? f.Answer
+                               : (f.AnswerLo ?? f.Answer),
                         f.CategoryId,
                         f.ViewCount,
                         f.IsFeatured
@@ -336,7 +336,7 @@ namespace Esim.Apis.Business
 
                 // Base query - only active devices
                 var query = dbContext.DeviceEsimCompatibilities
-                    .Where(d => d.Status == true && d.SupportsEsim == true);
+                    .Where(d => d.Status.HasValue && d.Status.Value && d.SupportsEsim.HasValue && d.SupportsEsim.Value);
 
                 // Filter by brand
                 if (!string.IsNullOrEmpty(request.brand))
@@ -353,7 +353,7 @@ namespace Esim.Apis.Business
                 // Filter by popular flag
                 if (request.isPopular.HasValue && request.isPopular.Value)
                 {
-                    query = query.Where(d => d.IsPopular == true);
+                    query = query.Where(d => d.IsPopular.HasValue && d.IsPopular.Value);
                 }
 
                 // Search by keyword in model name (all language variants)
@@ -382,13 +382,13 @@ namespace Esim.Apis.Business
                     {
                         d.Id,
                         d.Brand,
-                        modelName = lang == "en"
-                            ? (d.ModelNameEn ?? d.ModelName)
-                            : (d.ModelNameLo ?? d.ModelName),
+                        modelName = lang == "en" ? (d.ModelNameEn ?? d.ModelName)
+                                  : lang == "vi" ? d.ModelName
+                                  : (d.ModelNameLo ?? d.ModelName),
                         d.Category,
-                        notes = lang == "en"
-                            ? (d.NotesEn ?? d.Notes)
-                            : (d.NotesLo ?? d.Notes),
+                        notes = lang == "en" ? (d.NotesEn ?? d.Notes)
+                              : lang == "vi" ? d.Notes
+                              : (d.NotesLo ?? d.Notes),
                         supportsEsim = d.SupportsEsim ?? true,
                         isPopular = d.IsPopular ?? false,
                         displayOrder = d.DisplayOrder ?? 999
@@ -438,7 +438,7 @@ namespace Esim.Apis.Business
 
                 // Get all active devices for client-side filtering
                 var allDevices = dbContext.DeviceEsimCompatibilities
-                    .Where(d => d.Status == true && d.SupportsEsim == true)
+                    .Where(d => d.Status.HasValue && d.Status.Value && d.SupportsEsim.HasValue && d.SupportsEsim.Value)
                     .OrderBy(d => d.DisplayOrder)
                     .ThenBy(d => d.ModelName)
                     .ToList();
@@ -450,19 +450,19 @@ namespace Esim.Apis.Business
                     {
                         brand = g.Key,
                         deviceCount = g.Count(),
-                        popularCount = g.Count(d => d.IsPopular == true),
+                        popularCount = g.Count(d => d.IsPopular.HasValue && d.IsPopular.Value),
                         devices = g.Select(d => new
                         {
                             d.Id,
-                            modelName = lang == "en"
-                                ? (d.ModelNameEn ?? d.ModelName)
-                                : (d.ModelNameLo ?? d.ModelName),
+                            modelName = lang == "en" ? (d.ModelNameEn ?? d.ModelName)
+                                      : lang == "vi" ? d.ModelName
+                                      : (d.ModelNameLo ?? d.ModelName),
                             d.Category,
                             d.IsPopular,
                             d.DisplayOrder,
-                            notes = lang == "en"
-                                ? (d.NotesEn ?? d.Notes)
-                                : (d.NotesLo ?? d.Notes)
+                            notes = lang == "en" ? (d.NotesEn ?? d.Notes)
+                                  : lang == "vi" ? d.Notes
+                                  : (d.NotesLo ?? d.Notes)
                         }).ToList()
                     })
                     .OrderBy(b => b.brand)

+ 1 - 0
EsimLao/Esim.Apis/Business/User/IUserBusiness.cs

@@ -17,6 +17,7 @@ namespace Esim.Apis.Business
     {
         // Auth methods
         Task<IActionResult> RequestOtp(HttpRequest httpRequest, RequestOtpReq request);
+        Task<IActionResult> ResendOtp(HttpRequest httpRequest, RequestOtpReq request);
         Task<IActionResult> VerifyOtp(HttpRequest httpRequest, VerifyOtpReq request);
     }
 }

+ 187 - 9
EsimLao/Esim.Apis/Business/User/UserBusinessImpl.cs

@@ -168,7 +168,7 @@ namespace Esim.Apis.Business
 
                     // Query template and get language-specific content
                     var template = dbContext.MessageTemplates
-                        .FirstOrDefault(t => t.TemplateCode == templateCode && t.Status == true);
+                        .FirstOrDefault(t => t.TemplateCode == templateCode && t.Status.HasValue && t.Status.Value);
 
                     if (template == null)
                     {
@@ -176,15 +176,15 @@ namespace Esim.Apis.Business
                         throw new Exception($"Email template '{templateCode}' not found");
                     }
 
-                    // Get subject based on language (fallback to default column if _LO/_EN is null)
-                    string emailSubject = lang == "en"
-                        ? (template.SubjectEn ?? template.Subject ?? "")
-                        : (template.SubjectLo ?? template.Subject ?? "");
+                    // Get subject based on language: vi=default, en=_EN, lo=_LO (default)
+                    string emailSubject = lang == "en" ? (template.SubjectEn ?? template.Subject ?? "")
+                                        : lang == "vi" ? (template.Subject ?? "")
+                                        : (template.SubjectLo ?? template.Subject ?? "");
 
-                    // Get content based on language (fallback to default column if _LO/_EN is null)
-                    string emailContent = lang == "en"
-                        ? (template.ContentEn ?? template.Content ?? "")
-                        : (template.ContentLo ?? template.Content ?? "");
+                    // Get content based on language: vi=default, en=_EN, lo=_LO (default)
+                    string emailContent = lang == "en" ? (template.ContentEn ?? template.Content ?? "")
+                                        : lang == "vi" ? (template.Content ?? "")
+                                        : (template.ContentLo ?? template.Content ?? "");
 
                     // Replace placeholders in content
                     emailContent = emailContent
@@ -258,6 +258,184 @@ namespace Esim.Apis.Business
             );
         }
 
+        /// <summary>
+        /// Resend OTP - Only works if user has already requested OTP before
+        /// Has reduced cooldown (30 seconds vs 60 seconds for RequestOtp)
+        /// </summary>
+        public async Task<IActionResult> ResendOtp(HttpRequest httpRequest, RequestOtpReq request)
+        {
+            var url = httpRequest.Path;
+            var json = JsonConvert.SerializeObject(request);
+            log.Debug("URL: " + url + " => ResendOtp Request: " + json);
+            
+            try
+            {
+                string lang = CommonLogic.GetLanguage(httpRequest, request.lang);
+                
+                // Validate email is required
+                if (string.IsNullOrEmpty(request.email))
+                {
+                    return DotnetLib.Http.HttpResponse.BuildResponse(
+                        log,
+                        url,
+                        json,
+                        CommonErrorCode.RequiredFieldMissing,
+                        ConfigManager.Instance.GetConfigWebValue("EMAIL_REQUIRED", lang),
+                        new { }
+                    );
+                }
+
+                // Check if there's an existing OTP record for this email
+                var existingOtp = dbContext.OtpVerifications
+                    .Where(o => o.UserEmail == request.email)
+                    .OrderByDescending(o => o.CreatedDate)
+                    .FirstOrDefault();
+
+                // RESEND requires existing OTP - must have requested OTP before
+                if (existingOtp == null)
+                {
+                    log.Warn($"ResendOtp failed: No existing OTP record for {request.email}");
+                    return DotnetLib.Http.HttpResponse.BuildResponse(
+                        log,
+                        url,
+                        json,
+                        CommonErrorCode.OtpNotRequested,
+                        ConfigManager.Instance.GetConfigWebValue("OTP_NOT_REQUESTED", lang),
+                        new { }
+                    );
+                }
+
+                // RESEND has reduced cooldown: 60 seconds (vs 60 seconds for RequestOtp)
+                int minSecondsBetweenResend = 60;
+                var secondsSinceLastRequest = (DateTime.Now - (existingOtp.CreatedDate ?? DateTime.Now)).TotalSeconds;
+                
+                if (secondsSinceLastRequest < minSecondsBetweenResend)
+                {
+                    var waitSeconds = (int)(minSecondsBetweenResend - secondsSinceLastRequest);
+                    log.Warn($"ResendOtp: Too soon for {request.email}. Last request {secondsSinceLastRequest:F0}s ago.");
+                    
+                    return DotnetLib.Http.HttpResponse.BuildResponse(
+                        log,
+                        url,
+                        json,
+                        CommonErrorCode.OtpTooManyRequests,
+                        $"Please wait {waitSeconds} seconds before resending OTP.",
+                        new { 
+                            waitSeconds = waitSeconds,
+                            canResendAt = existingOtp.CreatedDate?.AddSeconds(minSecondsBetweenResend)
+                        }
+                    );
+                }
+
+                // Generate new 6-digit OTP (fixed 111111 for test account abc@gmail.com)
+                bool isTestAccount = request.email.ToLower() == "abc@gmail.com";
+                string otpCode = isTestAccount ? "111111" : GenerateOtp();
+                
+                // OTP expires in 5 minutes
+                int otpExpireMinutes = 5;
+
+                // Get customer ID (should exist since OTP record exists)
+                var customer = dbContext.CustomerInfos
+                    .Where(c => c.Email == request.email)
+                    .FirstOrDefault();
+                decimal? customerId = customer?.Id ?? existingOtp.CustomerId;
+
+                // UPDATE existing OTP record with new code and expiry
+                existingOtp.OtpCode = otpCode;
+                existingOtp.ExpiredAt = DateTime.Now.AddMinutes(otpExpireMinutes);
+                existingOtp.AttemptCount = 0; // Reset attempt count
+                existingOtp.IsUsed = false;   // Reset to unused
+                existingOtp.CustomerId = customerId;
+                existingOtp.CreatedDate = DateTime.Now; // Update to track resend time
+                
+                log.Info($"ResendOtp: Updated OTP record ID={existingOtp.Id} for {request.email}");
+                
+                await dbContext.SaveChangesAsync();
+
+                // Skip email sending for test account
+                if (!isTestAccount)
+                {
+                    // Add to MESSAGE_QUEUE for background email sending
+                    string templateCode = "OTP_LOGIN";
+                    
+                    var template = dbContext.MessageTemplates
+                        .FirstOrDefault(t => t.TemplateCode == templateCode && t.Status.HasValue && t.Status.Value);
+
+                    if (template == null)
+                    {
+                        log.Error($"Template '{templateCode}' not found in MESSAGE_TEMPLATE");
+                        throw new Exception($"Email template '{templateCode}' not found");
+                    }
+
+                    // Get subject based on language
+                    string emailSubject = lang == "en" ? (template.SubjectEn ?? template.Subject ?? "")
+                                        : lang == "vi" ? (template.Subject ?? "")
+                                        : (template.SubjectLo ?? template.Subject ?? "");
+
+                    // Get content based on language
+                    string emailContent = lang == "en" ? (template.ContentEn ?? template.Content ?? "")
+                                        : lang == "vi" ? (template.Content ?? "")
+                                        : (template.ContentLo ?? template.Content ?? "");
+
+                    // Replace placeholders
+                    emailContent = emailContent
+                        .Replace("{{OTP_CODE}}", otpCode)
+                        .Replace("{{EXPIRE_MINUTES}}", otpExpireMinutes.ToString());
+
+                    emailSubject = emailSubject
+                        .Replace("{{OTP_CODE}}", otpCode)
+                        .Replace("{{EXPIRE_MINUTES}}", otpExpireMinutes.ToString());
+
+                    var emailMessageID = (int)await Database.DbLogic.GenIdAsync(dbContext, "MESSAGE_QUEUE_SEQ");
+                    var emailMessage = new MessageQueue
+                    {
+                        Id = emailMessageID,
+                        MessageType = 1, // Email
+                        Recipient = request.email,
+                        Subject = emailSubject,
+                        Content = emailContent,
+                        Priority = true, // High priority
+                        Status = 0, // Pending
+                        ScheduledAt = DateTime.Now,
+                        RetryCount = 0,
+                        MaxRetry = 3,
+                        CreatedBy = customerId,
+                        CreatedDate = DateTime.Now
+                    };
+
+                    dbContext.MessageQueues.Add(emailMessage);
+                    await dbContext.SaveChangesAsync();
+                }
+
+                log.Info($"ResendOtp: OTP resent for {request.email}: {otpCode} - {(isTestAccount ? "Test account, no email sent" : "Email queued")}");
+
+                return DotnetLib.Http.HttpResponse.BuildResponse(
+                    log,
+                    url,
+                    json,
+                    CommonErrorCode.Success,
+                    ConfigManager.Instance.GetConfigWebValue("OTP_RESENT_SUCCESS", lang),
+                    new
+                    {
+                        email = request.email,
+                        expireInSeconds = otpExpireMinutes * 60
+                    }
+                );
+            }
+            catch (Exception exception)
+            {
+                log.Error("ResendOtp Exception: ", exception);
+            }
+            return DotnetLib.Http.HttpResponse.BuildResponse(
+                log,
+                url,
+                json,
+                CommonErrorCode.SystemError,
+                ConfigManager.Instance.GetConfigWebValue("SYSTEM_FAILURE"),
+                new { }
+            );
+        }
+
         /// <summary>
         /// Verify OTP and complete login - return JWT token
         /// </summary>

+ 11 - 0
EsimLao/Esim.Apis/Controllers/UserController.cs

@@ -48,6 +48,17 @@ namespace RevoSystem.Apis.Controllers
             return await userBusiness.RequestOtp(HttpContext.Request, request);
         }
 
+        /// <summary>
+        /// Resend OTP via email
+        /// POST /apis/auth/resend-otp
+        /// </summary>
+        [HttpPost]
+        [Route(ApiUrlConstant.ResendOtpUrl)]
+        public async Task<IActionResult> ResendOtp([FromBody] RequestOtpReq request)
+        {
+            return await userBusiness.ResendOtp(HttpContext.Request, request);
+        }
+
         /// <summary>
         /// Verify OTP and complete login
         /// POST /apis/auth/verify-otp

+ 20 - 7
EsimLao/Esim.Apis/Singleton/ConfigManager.cs

@@ -34,8 +34,8 @@ namespace Esim.Apis.Singleton
             var config = appConfigs.FirstOrDefault(c => c.Name == configName && c.Type == "WEB");
             if (config != null)
             {
-                log.Debug($"Config found: {configName} = {config.ValueGlobal}");
-                return lang == "en" ? config.ValueGlobal : config.ValueLocal;
+                log.Debug($"Config found: {configName} = {config.ValueLocal}");
+                return GetValueByLang(config, lang);
             }
             log.Warn($"Config not found: {configName}");
             return string.Empty;
@@ -46,25 +46,38 @@ namespace Esim.Apis.Singleton
             var config = appConfigs.FirstOrDefault(c => c.Name == configName && c.Type == "SMS");
             if (config != null)
             {
-                log.Debug($"Config found: {configName} = {config.ValueGlobal}");
-                return lang == "en" ? config.ValueGlobal : config.ValueLocal;
+                log.Debug($"Config found: {configName} = {config.ValueLocal}");
+                return GetValueByLang(config, lang);
             }
             log.Warn($"Config not found: {configName}");
             return string.Empty;
         }
 
-        public string GetConfigAppValue(string configName, string lang = "te")
+        public string GetConfigAppValue(string configName, string lang = "lo")
         {
             var config = appConfigs.FirstOrDefault(c => c.Name == configName && c.Type == "APP");
             if (config != null)
             {
-                log.Debug($"Config found: {configName} = {config.ValueGlobal}");
-                return lang == "en" ? config.ValueGlobal : config.ValueLocal;
+                log.Debug($"Config found: {configName} = {config.ValueLocal}");
+                return GetValueByLang(config, lang);
             }
             log.Warn($"Config not found: {configName}");
             return string.Empty;
         }
 
+        /// <summary>
+        /// Get config value by language: vi=Vietnamese, en=English, lo=Lao (default)
+        /// </summary>
+        private string GetValueByLang(Config config, string lang)
+        {
+            return lang switch
+            {
+                "vi" => config.Value ?? config.ValueLocal ?? "",
+                "en" => config.ValueGlobal ?? config.ValueLocal ?? "",
+                _ => config.ValueLocal ?? ""  // Default: Lao
+            };
+        }
+
         // loop 5p to refresh
         public void RefreshConfigs()
         {