diff --git a/src/food-market.api/Controllers/AuthSignupController.cs b/src/food-market.api/Controllers/AuthSignupController.cs index 4a0f67e..8fec3cf 100644 --- a/src/food-market.api/Controllers/AuthSignupController.cs +++ b/src/food-market.api/Controllers/AuthSignupController.cs @@ -31,19 +31,6 @@ public AuthSignupController(AppDbContext db, UserManager userMgr) public record SignupInput(string Email, string Password, string OrganizationName, string Phone, string? Plan); public record SignupResult(Guid OrganizationId, string Email); - /// Нормализация и проверка телефона Казахстана. Принимаем любое - /// форматирование (пробелы, скобки, +, дефисы), оставляем только цифры, - /// ведущая «8» переписывается в «7». Валидно: ровно 11 цифр, начинается - /// с «77» — мобильный код KZ. «79…» (РФ) и прочие отвергаем. - private static string? NormalizeKzPhone(string? raw) - { - if (string.IsNullOrWhiteSpace(raw)) return null; - var digits = new string(raw.Where(char.IsDigit).ToArray()); - if (digits.Length == 11 && digits[0] == '8') digits = "7" + digits[1..]; - if (digits.Length != 11 || !digits.StartsWith("77")) return null; - return "+" + digits; - } - [HttpPost("signup")] public async Task> Signup([FromBody] SignupInput input, CancellationToken ct) { @@ -52,9 +39,9 @@ public async Task> Signup([FromBody] SignupInput inpu return BadRequest(new { error = "Email, пароль и название обязательны." }); if (input.Password.Length < 8) return BadRequest(new { error = "Пароль минимум 8 символов." }); - var normalizedPhone = NormalizeKzPhone(input.Phone); + var normalizedPhone = foodmarket.Application.Common.PhoneNormalization.TryNormalizeKz(input.Phone); if (normalizedPhone is null) - return BadRequest(new { error = "Введите корректный номер Казахстана. Пример: +7 700 123 45 67" }); + return BadRequest(new { error = foodmarket.Application.Common.PhoneNormalization.ErrorMessage }); var existing = await _userMgr.FindByEmailAsync(input.Email); if (existing is not null) diff --git a/src/food-market.api/Controllers/Catalog/CounterpartiesController.cs b/src/food-market.api/Controllers/Catalog/CounterpartiesController.cs index 8edfe19..6ca8c87 100644 --- a/src/food-market.api/Controllers/Catalog/CounterpartiesController.cs +++ b/src/food-market.api/Controllers/Catalog/CounterpartiesController.cs @@ -72,6 +72,7 @@ public async Task> Get(Guid id, CancellationToken [HttpPost, Authorize(Roles = "Admin,Storekeeper")] public async Task> Create([FromBody] CounterpartyInput input, CancellationToken ct) { + if (NormalizePhoneOrError(input.Phone) is { } err) return BadRequest(new { error = err }); var e = Apply(new Counterparty(), input); _db.Counterparties.Add(e); await _db.SaveChangesAsync(ct); @@ -81,6 +82,7 @@ public async Task> Create([FromBody] CounterpartyI [HttpPut("{id:guid}"), Authorize(Roles = "Admin,Storekeeper")] public async Task Update(Guid id, [FromBody] CounterpartyInput input, CancellationToken ct) { + if (NormalizePhoneOrError(input.Phone) is { } err) return BadRequest(new { error = err }); var e = await _db.Counterparties.FirstOrDefaultAsync(x => x.Id == id, ct); if (e is null) return NotFound(); Apply(e, input); @@ -88,6 +90,14 @@ public async Task Update(Guid id, [FromBody] CounterpartyInput in return NoContent(); } + private static string? NormalizePhoneOrError(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) return null; + return foodmarket.Application.Common.PhoneNormalization.IsValidOrEmpty(raw) + ? null + : foodmarket.Application.Common.PhoneNormalization.ErrorMessage; + } + [HttpDelete("{id:guid}"), Authorize(Roles = "Admin")] public async Task Delete(Guid id, CancellationToken ct) { @@ -108,7 +118,7 @@ private static Counterparty Apply(Counterparty e, CounterpartyInput i) e.TaxNumber = i.TaxNumber; e.CountryId = i.CountryId; e.Address = i.Address; - e.Phone = i.Phone; + e.Phone = foodmarket.Application.Common.PhoneNormalization.TryNormalizeKz(i.Phone); e.Email = i.Email; e.BankName = i.BankName; e.BankAccount = i.BankAccount; diff --git a/src/food-market.api/Controllers/Organizations/EmployeesController.cs b/src/food-market.api/Controllers/Organizations/EmployeesController.cs index da5c0a4..f309166 100644 --- a/src/food-market.api/Controllers/Organizations/EmployeesController.cs +++ b/src/food-market.api/Controllers/Organizations/EmployeesController.cs @@ -217,7 +217,10 @@ public async Task Update(Guid id, [FromBody] EmployeeInput input, e.MiddleName = input.MiddleName; e.Position = input.Position; e.Email = input.Email; - e.Phone = input.Phone; + if (!string.IsNullOrWhiteSpace(input.Phone) + && !foodmarket.Application.Common.PhoneNormalization.IsValidOrEmpty(input.Phone)) + return BadRequest(new { error = foodmarket.Application.Common.PhoneNormalization.ErrorMessage }); + e.Phone = foodmarket.Application.Common.PhoneNormalization.TryNormalizeKz(input.Phone); e.Salary = input.Salary; e.TaxNumber = input.TaxNumber; e.Description = input.Description; diff --git a/src/food-market.api/Controllers/SuperAdmin/SuperAdminEmployeesController.cs b/src/food-market.api/Controllers/SuperAdmin/SuperAdminEmployeesController.cs index 02a3337..15c5dec 100644 --- a/src/food-market.api/Controllers/SuperAdmin/SuperAdminEmployeesController.cs +++ b/src/food-market.api/Controllers/SuperAdmin/SuperAdminEmployeesController.cs @@ -168,11 +168,15 @@ public async Task> Create(Guid orgId, [FromBody] Cr newUserId = u.Id; } + if (!foodmarket.Application.Common.PhoneNormalization.IsValidOrEmpty(input.Phone)) + return BadRequest(new { error = foodmarket.Application.Common.PhoneNormalization.ErrorMessage }); + var emp = new Employee { OrganizationId = orgId, UserId = newUserId, LastName = input.LastName, FirstName = input.FirstName, MiddleName = input.MiddleName, - Position = input.Position, Email = input.Email, Phone = input.Phone, + Position = input.Position, Email = input.Email, + Phone = foodmarket.Application.Common.PhoneNormalization.TryNormalizeKz(input.Phone), RoleId = input.RoleId, IsActive = input.IsActive, }; foreach (var rp in input.RetailPointIds ?? []) @@ -206,7 +210,9 @@ public async Task Update(Guid orgId, Guid id, [FromBody] UpdateIn e.MiddleName = input.Employee.MiddleName; e.Position = input.Employee.Position; e.Email = input.Employee.Email; - e.Phone = input.Employee.Phone; + if (!foodmarket.Application.Common.PhoneNormalization.IsValidOrEmpty(input.Employee.Phone)) + return BadRequest(new { error = foodmarket.Application.Common.PhoneNormalization.ErrorMessage }); + e.Phone = foodmarket.Application.Common.PhoneNormalization.TryNormalizeKz(input.Employee.Phone); e.Salary = input.Employee.Salary; e.TaxNumber = input.Employee.TaxNumber; e.Description = input.Employee.Description; diff --git a/src/food-market.api/Controllers/SuperAdmin/SuperAdminOrganizationsController.cs b/src/food-market.api/Controllers/SuperAdmin/SuperAdminOrganizationsController.cs index 896cd3f..c7e0ed4 100644 --- a/src/food-market.api/Controllers/SuperAdmin/SuperAdminOrganizationsController.cs +++ b/src/food-market.api/Controllers/SuperAdmin/SuperAdminOrganizationsController.cs @@ -92,11 +92,15 @@ public async Task> Get(Guid id, CancellationToken ct) [HttpPost] public async Task> Create([FromBody] CreateOrgRequest input, CancellationToken ct) { + var phone = foodmarket.Application.Common.PhoneNormalization.TryNormalizeKz(input.Org.Phone); + if (!string.IsNullOrEmpty(input.Org.Phone) && phone is null) + return BadRequest(new { error = foodmarket.Application.Common.PhoneNormalization.ErrorMessage }); + var org = new Organization { Name = input.Org.Name, CountryCode = input.Org.CountryCode, Bin = input.Org.Bin, Address = input.Org.Address, - Phone = input.Org.Phone, Email = input.Org.Email, + Phone = phone, Email = input.Org.Email, DefaultCurrencyId = input.Org.DefaultCurrencyId, }; _db.Organizations.Add(org); @@ -147,10 +151,13 @@ public async Task Update(Guid id, [FromBody] OrgInput input, Canc { var o = await _db.Organizations.IgnoreQueryFilters().FirstOrDefaultAsync(x => x.Id == id, ct); if (o is null) return NotFound(); + var phone = foodmarket.Application.Common.PhoneNormalization.TryNormalizeKz(input.Phone); + if (!string.IsNullOrEmpty(input.Phone) && phone is null) + return BadRequest(new { error = foodmarket.Application.Common.PhoneNormalization.ErrorMessage }); var before = $"{{\"name\":\"{o.Name}\",\"bin\":\"{o.Bin}\"}}"; o.Name = input.Name; o.CountryCode = input.CountryCode; o.Bin = input.Bin; o.Address = input.Address; - o.Phone = input.Phone; o.Email = input.Email; + o.Phone = phone; o.Email = input.Email; o.DefaultCurrencyId = input.DefaultCurrencyId; await _db.SaveChangesAsync(ct); var after = $"{{\"name\":\"{o.Name}\",\"bin\":\"{o.Bin}\"}}"; diff --git a/src/food-market.application/Common/PhoneNormalization.cs b/src/food-market.application/Common/PhoneNormalization.cs new file mode 100644 index 0000000..91a8c71 --- /dev/null +++ b/src/food-market.application/Common/PhoneNormalization.cs @@ -0,0 +1,35 @@ +namespace foodmarket.Application.Common; + +/// Серверная валидация и нормализация телефонов Казахстана. +/// Принимает любое форматирование (+7 700 123-45-67, 8 (700) 1234567, ...), +/// оставляет только цифры, ведущая «8» переписывается в «7». +/// Валидно: ровно 11 цифр, начинается с «77» — мобильный код KZ. +/// «79…» (РФ) и прочие отвергаются. +/// +/// Используется на всех endpoint'ах, принимающих phone — defense-in-depth +/// к фронт-валидации (PhoneInput / validatePhone). +public static class PhoneNormalization +{ + public const string ErrorMessage = "Введите корректный номер Казахстана. Пример: +7 700 123 45 67"; + + /// Возвращает нормализованный номер вида "+77001234567" если phone валиден, + /// null если не валиден. Пустой ввод возвращает null без ошибки — + /// проверку «обязательно ли» делает вызывающая сторона. + public static string? TryNormalizeKz(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) return null; + var digits = new string(raw.Where(char.IsDigit).ToArray()); + if (digits.Length == 11 && digits[0] == '8') digits = "7" + digits[1..]; + if (digits.Length != 11 || !digits.StartsWith("77")) return null; + return "+" + digits; + } + + /// True если phone валиден или null/пусто (опциональное поле). + /// False — только если непустой ввод не парсится. Удобно для контроллеров, + /// которые принимают phone как опциональный. + public static bool IsValidOrEmpty(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) return true; + return TryNormalizeKz(raw) is not null; + } +}