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; /// Самообслуживание: регистрация новой организации с публичного /// маркетингового сайта. Создаёт Organization + bootstrap (Stores, Roles, /// Units, PriceTypes, Cassa) + первого Owner-Employee-AppUser-Admin. /// /// Токены НЕ выпускаются здесь — фронт получает их обычным запросом /// /connect/token (password grant) сразу после успешного signup. Это /// убирает дублирование с OpenIddict и упрощает контракт. Phase 6: без /// email-верификации (переедет в Phase 7). [ApiController] [Route("api/auth")] public class AuthSignupController : ControllerBase { private readonly AppDbContext _db; private readonly UserManager _userMgr; public AuthSignupController(AppDbContext db, UserManager 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> 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); } }