food-market/src/food-market.api/Controllers/AuthSignupController.cs
nns 46877cc134 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 остаётся валидным для опциональных полей —
контроллер сам решает требовать или нет.
2026-05-08 01:05:48 +05:00

137 lines
6.5 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using foodmarket.Api.Seed;
using foodmarket.Domain.Organizations;
using foodmarket.Infrastructure.Identity;
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers;
/// <summary>Самообслуживание: регистрация новой организации с публичного
/// маркетингового сайта. Создаёт Organization + bootstrap (Stores, Roles,
/// Units, PriceTypes, Cassa) + первого Owner-Employee-AppUser-Admin.
///
/// Токены НЕ выпускаются здесь — фронт получает их обычным запросом
/// /connect/token (password grant) сразу после успешного signup. Это
/// убирает дублирование с OpenIddict и упрощает контракт. Phase 6: без
/// email-верификации (переедет в Phase 7).</summary>
[ApiController]
[Route("api/auth")]
public class AuthSignupController : ControllerBase
{
private readonly AppDbContext _db;
private readonly UserManager<User> _userMgr;
public AuthSignupController(AppDbContext db, UserManager<User> userMgr)
{
_db = db; _userMgr = userMgr;
}
public record SignupInput(string Email, string Password, string OrganizationName, string Phone, string? Plan);
public record SignupResult(Guid OrganizationId, string Email);
[HttpPost("signup")]
public async Task<ActionResult<SignupResult>> Signup([FromBody] SignupInput input, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(input.Email) || string.IsNullOrWhiteSpace(input.Password)
|| string.IsNullOrWhiteSpace(input.OrganizationName))
return BadRequest(new { error = "Email, пароль и название обязательны." });
if (input.Password.Length < 8)
return BadRequest(new { error = "Пароль минимум 8 символов." });
var normalizedPhone = foodmarket.Application.Common.PhoneNormalization.TryNormalizeKz(input.Phone);
if (normalizedPhone is null)
return BadRequest(new { error = foodmarket.Application.Common.PhoneNormalization.ErrorMessage });
var existing = await _userMgr.FindByEmailAsync(input.Email);
if (existing is not null)
{
// Если AppUser orphan (org удалена / он деактивирован) — реактивируем
// его с новой organization вместо отказа. SuperAdmin'а трогать
// нельзя: у него email тоже email и блокировка не должна давать
// обходить вход.
var isSuperAdmin = await _userMgr.IsInRoleAsync(existing, "SuperAdmin");
var orgAlive = existing.OrganizationId is not null
&& await _db.Organizations.IgnoreQueryFilters()
.AnyAsync(o => o.Id == existing.OrganizationId.Value && !o.IsArchived, ct);
if (isSuperAdmin || orgAlive)
return BadRequest(new { error = "Пользователь с таким email уже зарегистрирован." });
// Иначе — переиспользуем существующий AppUser ниже.
}
// 1. Organization + полный bootstrap tenant-сущностей.
var kzt = await _db.Currencies.FirstOrDefaultAsync(c => c.Code == "KZT", ct);
var org = new Organization
{
Name = input.OrganizationName.Trim(),
CountryCode = "KZ",
DefaultCurrencyId = kzt?.Id,
Phone = normalizedPhone,
Email = input.Email.Trim(),
};
_db.Organizations.Add(org);
await _db.SaveChangesAsync(ct);
await DevDataSeeder.SeedTenantReferencesAsync(_db, org.Id, ct);
// 2. AppUser — либо новый, либо реактивируем orphan (см. выше).
User user;
if (existing is not null)
{
user = existing;
user.OrganizationId = org.Id;
user.IsActive = true;
user.FullName = input.OrganizationName.Trim();
user.Email = input.Email.Trim();
user.EmailConfirmed = true;
await _userMgr.UpdateAsync(user);
// Сброс пароля на тот, что юзер ввёл сейчас.
await _userMgr.RemovePasswordAsync(user);
var pwRes = await _userMgr.AddPasswordAsync(user, input.Password);
if (!pwRes.Succeeded)
{
_db.Organizations.Remove(org);
await _db.SaveChangesAsync(ct);
return BadRequest(new { error = string.Join("; ", pwRes.Errors.Select(e => e.Description)) });
}
// Roles: убедимся что есть Admin. SuperAdmin сюда не попадает (отсечено выше).
if (!await _userMgr.IsInRoleAsync(user, "Admin"))
await _userMgr.AddToRoleAsync(user, "Admin");
}
else
{
user = new User
{
UserName = input.Email.Trim(),
Email = input.Email.Trim(),
EmailConfirmed = true,
FullName = input.OrganizationName.Trim(),
OrganizationId = org.Id,
IsActive = true,
};
var ur = await _userMgr.CreateAsync(user, input.Password);
if (!ur.Succeeded)
{
_db.Organizations.Remove(org);
await _db.SaveChangesAsync(ct);
return BadRequest(new { error = string.Join("; ", ur.Errors.Select(e => e.Description)) });
}
await _userMgr.AddToRoleAsync(user, "Admin");
}
// 3. Owner Employee с системной ролью «Администратор».
var adminRole = await _db.EmployeeRoles.IgnoreQueryFilters()
.FirstAsync(r => r.OrganizationId == org.Id && r.IsSystem && r.Name == "Администратор", ct);
_db.Employees.Add(new Employee
{
OrganizationId = org.Id, UserId = user.Id,
LastName = input.OrganizationName.Trim(), FirstName = "Owner",
Position = "Владелец", Email = input.Email.Trim(),
RoleId = adminRole.Id, IsActive = true,
});
org.AccountOwnerUserId = user.Id;
await _db.SaveChangesAsync(ct);
return new SignupResult(org.Id, user.Email);
}
}