fix(phone): серверная KZ-ФЛК на всех endpoint'ах принимающих phone
Logic gap из e2e-отчёта: SuperAdmin /organizations принимал любой текст в Phone — серверной валидации ФЛК не было (только в /api/auth/signup). Это позволяло сохранить «abc» в Organization.Phone и невалидные номера для контрагентов и сотрудников. — Application/Common/PhoneNormalization.cs (новый): TryNormalizeKz + IsValidOrEmpty. Принимает любое форматирование, ведущая «8» → «7»; валидно: 11 цифр, начинается с «77» (мобильный код KZ). — SuperAdminOrganizationsController.Create/Update: 400 если phone не парсится; в БД пишется нормализованная форма «+77001234567». — CounterpartiesController.Create/Update: то же. Apply() нормализует. — EmployeesController.Create/Update: то же. — SuperAdminEmployeesController.Create/Update: то же. — AuthSignupController: убран локальный NormalizeKzPhone, используется shared. Сообщение об ошибке унифицировано. Defense-in-depth к фронтовой валидации (PhoneInput / validatePhone). Незаполненный phone остаётся валидным для опциональных полей — контроллер сам решает требовать или нет.
This commit is contained in:
parent
bcf81c57ee
commit
46877cc134
|
|
@ -31,19 +31,6 @@ public AuthSignupController(AppDbContext db, UserManager<User> userMgr)
|
|||
public record SignupInput(string Email, string Password, string OrganizationName, string Phone, string? Plan);
|
||||
public record SignupResult(Guid OrganizationId, string Email);
|
||||
|
||||
/// <summary>Нормализация и проверка телефона Казахстана. Принимаем любое
|
||||
/// форматирование (пробелы, скобки, +, дефисы), оставляем только цифры,
|
||||
/// ведущая «8» переписывается в «7». Валидно: ровно 11 цифр, начинается
|
||||
/// с «77» — мобильный код KZ. «79…» (РФ) и прочие отвергаем.</summary>
|
||||
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<ActionResult<SignupResult>> Signup([FromBody] SignupInput input, CancellationToken ct)
|
||||
{
|
||||
|
|
@ -52,9 +39,9 @@ public async Task<ActionResult<SignupResult>> 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)
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ public async Task<ActionResult<CounterpartyDto>> Get(Guid id, CancellationToken
|
|||
[HttpPost, Authorize(Roles = "Admin,Storekeeper")]
|
||||
public async Task<ActionResult<CounterpartyDto>> 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<ActionResult<CounterpartyDto>> Create([FromBody] CounterpartyI
|
|||
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Storekeeper")]
|
||||
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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;
|
||||
|
|
|
|||
|
|
@ -217,7 +217,10 @@ public async Task<IActionResult> 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;
|
||||
|
|
|
|||
|
|
@ -168,11 +168,15 @@ public async Task<ActionResult<EmployeeDetail>> 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<IActionResult> 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;
|
||||
|
|
|
|||
|
|
@ -92,11 +92,15 @@ public async Task<ActionResult<OrgDetail>> Get(Guid id, CancellationToken ct)
|
|||
[HttpPost]
|
||||
public async Task<ActionResult<CreateOrgResult>> 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<IActionResult> 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}\"}}";
|
||||
|
|
|
|||
35
src/food-market.application/Common/PhoneNormalization.cs
Normal file
35
src/food-market.application/Common/PhoneNormalization.cs
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
namespace foodmarket.Application.Common;
|
||||
|
||||
/// <summary>Серверная валидация и нормализация телефонов Казахстана.
|
||||
/// Принимает любое форматирование (+7 700 123-45-67, 8 (700) 1234567, ...),
|
||||
/// оставляет только цифры, ведущая «8» переписывается в «7».
|
||||
/// Валидно: ровно 11 цифр, начинается с «77» — мобильный код KZ.
|
||||
/// «79…» (РФ) и прочие отвергаются.
|
||||
///
|
||||
/// Используется на всех endpoint'ах, принимающих phone — defense-in-depth
|
||||
/// к фронт-валидации (PhoneInput / validatePhone).</summary>
|
||||
public static class PhoneNormalization
|
||||
{
|
||||
public const string ErrorMessage = "Введите корректный номер Казахстана. Пример: +7 700 123 45 67";
|
||||
|
||||
/// <summary>Возвращает нормализованный номер вида "+77001234567" если phone валиден,
|
||||
/// null если не валиден. Пустой ввод возвращает null без ошибки —
|
||||
/// проверку «обязательно ли» делает вызывающая сторона.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>True если phone валиден или null/пусто (опциональное поле).
|
||||
/// False — только если непустой ввод не парсится. Удобно для контроллеров,
|
||||
/// которые принимают phone как опциональный.</summary>
|
||||
public static bool IsValidOrEmpty(string? raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw)) return true;
|
||||
return TryNormalizeKz(raw) is not null;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue