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
Новый пакет 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>
97 lines
4.4 KiB
C#
97 lines
4.4 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 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);
|
||
}
|
||
}
|