food-market/src/food-market.api/Controllers/AuthSignupController.cs
nns a4cbb06bb3
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 48s
CI / Web (React + Vite) (push) Successful in 38s
Docker API / Build + push API (push) Successful in 50s
Docker Web / Build + push Web (push) Successful in 40s
Docker API / Deploy API on stage (push) Successful in 18s
Docker Web / Deploy Web on stage (push) Successful in 11s
feat(public): Phase 6 — публичный маркетинговый сайт food-market.public на Astro
Новый пакет src/food-market.public/ — отдельный фронт для маркетинга и
самостоятельной регистрации магазинов-клиентов в SaaS Food Market.
Существующая админка food-market.web НЕ затронута.

Стек: Astro 4 + React 19 islands + Tailwind v3 (палитра идентична
food-market.web — единый бренд), TypeScript 6, content collections для
юр.документов. Static-сайт через nginx, gzip + immutable cache на assets.

Карта страниц (23):
- /                            — главная (Hero + 3 выгоды + скриншот +
                                   6 вертикалей + 6 модулей + Касса +
                                   Интеграции + 3 тарифа + соцпруф +
                                   FAQ + финальный CTA)
- /features                    — модули по сценариям
- /pricing                     — тарифы + интерактивный конструктор
                                   «Бизнес» (per-unit: 10000 база +
                                   2000/магазин + 500/касса + 500/склад,
                                   слайдеры, передача params в /signup)
- /pos                         — УТП лендинг кассы для Windows + весов
- /migration-from-moysklad     — УТП лендинг миграции с МойСклад
                                   (сравнительная таблица + 3 шага)
- /integrations                — список интеграций
- /for-grocery|pharmacy|cafe|alcohol|clothing|household — 6 вертикалей
                                   с уникальными фишками (весовой,
                                   серии/сроки, модификаторы и комбо,
                                   акцизные марки, размерные сетки,
                                   гарантийные сроки)
- /signup                      — регистрация (React-island форма)
- /about /contacts /kb /blog /status /changelog — компания + ресурсы
- /legal/{offer,privacy,consent,requisites} — реальные юр.документы
                                   из /tmp/legal/ как Astro content
                                   collection (markdown с frontmatter,
                                   динамический [slug].astro template,
                                   720px max-width, line-height 1.7,
                                   prose-legal стили)
- /sitemap.xml                 — ручной генератор (sitemap-плагин
                                   конфликтует с Astro 4.16, заменён
                                   на простой APIRoute)

React-острова (3):
- BusinessTariffBuilder — слайдеры + расчёт total + ссылка на signup
- SignupForm — email/password/orgName/phone/plan + валидация + agree
- FAQ — accordion 7 вопросов

API: новый POST /api/auth/signup создаёт Organization + AppUser
(Identity Admin role) + Owner Employee + полный bootstrap через
DevDataSeeder.SeedTenantReferencesAsync (units, price-types, store,
cassa, 6 ролей). Токены НЕ выпускает — фронт сразу делает обычный
/connect/token (password grant) и получает access/refresh без
дублирования OpenIddict-логики. На signup-форме — auth-bridge:
токены передаются через URL fragment в админку
APP_URL/auth-bridge#access=...&refresh=...&welcome=1, AuthBridgePage
кладёт в localStorage и редиректит на /?welcome=signup.

URL-домены через env-переменные (юзер ещё выбирает финальный):
- PUBLIC_SITE_URL — canonical/OG/sitemap (default https://food-market.kz)
- PUBLIC_APP_URL — admin/API endpoint (default https://food-market.zat.kz)
Nginx-конфиг для деплоя сайта — заготовка-template в
deploy/nginx/food-market-public.conf.template, не применён —
ждёт решения по домену.

Dockerfile multi-stage (node:20-alpine build → nginx:1.27 runtime),
build-args PUBLIC_SITE_URL/PUBLIC_APP_URL, deploy/nginx.conf gzip +
immutable cache + try_files для pretty URLs.

SEO: OG-теги, twitter-card, canonical, JSON-LD SoftwareApplication
схема, robots.txt → sitemap-index, lang=ru-KZ.

Admin-side: /auth-bridge route в food-market.web — принимает токены
из URL fragment, кладёт в localStorage (fm.access_token / fm.refresh_token),
редиректит на /. Fragment чтобы access_token не попадал в Referer.

23 страницы билдятся без ошибок. Контейнер собирается. Деплой на
конкретный домен — отдельным шагом после решения юзера.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 19:11:34 +05:00

97 lines
4.4 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 existing = await _userMgr.FindByEmailAsync(input.Email);
if (existing is not null)
return BadRequest(new { error = "Пользователь с таким email уже зарегистрирован." });
// 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 = string.IsNullOrWhiteSpace(input.Phone) ? null : input.Phone.Trim(),
Email = input.Email.Trim(),
};
_db.Organizations.Add(org);
await _db.SaveChangesAsync(ct);
await DevDataSeeder.SeedTenantReferencesAsync(_db, org.Id, ct);
// 2. AppUser в роли Identity Admin, привязан к этой организации.
var 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)
{
// Откат: убираем органзацию чтобы не оставить orphan.
_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);
}
}