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 остаётся валидным для опциональных полей — контроллер сам решает требовать или нет.
137 lines
6.5 KiB
C#
137 lines
6.5 KiB
C#
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);
|
||
}
|
||
}
|