feat(platform): PlatformSettings entity + миграция (singleton SMTP-конфиг)

Платформенные настройки: один row, не tenant-scoped, видны и меняются
только Супер-администратором. Хранят SMTP-креды для исходящей почты
(forgot-password, нотификации). IMAP к этому отношения не имеет — IMAP
для чтения входящей, нам нужен SMTP.

Поля:
- SmtpHost, SmtpPort
- SmtpUseSsl (implicit TLS / 465) и SmtpStartTls (587, по дефолту true)
- SmtpUsername, SmtpPasswordEncrypted (хранится зашифрованным через
  DataProtection API; в API-ответах не выходит, только has-password флаг)
- FromEmail, FromName

Миграция Phase5b_PlatformSettings создаёт таблицу public.platform_settings.
Конфиг EF в AppDbContext: HasMaxLength для строк.
This commit is contained in:
nns 2026-05-06 12:35:48 +05:00
parent fc9f7c9ee4
commit 1456f170eb
3 changed files with 85 additions and 0 deletions

View file

@ -0,0 +1,36 @@
using foodmarket.Domain.Common;
namespace foodmarket.Domain.Platform;
/// <summary>Платформенные настройки (singleton, single-row). Хранят SMTP-креды
/// для отправки писем (forgot-password, инвайты, нотификации). Не tenant-scoped —
/// общий конфиг для всей платформы, видны и меняются только Супер-администратором.
///
/// SmtpPassword хранится зашифрованным через DataProtection API
/// (`IDataProtectionProvider.CreateProtector("foodmarket.smtp")`); снаружи
/// (контроллер) — никогда не возвращается в открытом виде, только has-password флаг.</summary>
public class PlatformSettings : Entity
{
/// <summary>SMTP-сервер для отправки исходящей почты (НЕ IMAP — IMAP это
/// для чтения входящей).</summary>
public string? SmtpHost { get; set; }
public int? SmtpPort { get; set; }
/// <summary>Implicit TLS (SMTPS, обычно порт 465). Взаимоисключающий
/// со SmtpStartTls (587).</summary>
public bool SmtpUseSsl { get; set; }
/// <summary>STARTTLS upgrade (обычно порт 587). По дефолту true в большинстве
/// современных провайдеров (Gmail/Yandex/Mailgun).</summary>
public bool SmtpStartTls { get; set; } = true;
public string? SmtpUsername { get; set; }
/// <summary>Зашифрованный SmtpPassword (base64 через DataProtection).
/// Никогда не отдаётся в API-ответах. Установка только через PUT с
/// явно переданным new-password полем.</summary>
public string? SmtpPasswordEncrypted { get; set; }
public string? FromEmail { get; set; }
public string? FromName { get; set; }
}

View file

@ -51,6 +51,7 @@ public AppDbContext(DbContextOptions<AppDbContext> options, ITenantContext tenan
public DbSet<EmployeeRetailPointAssignment> EmployeeRetailPointAssignments => Set<EmployeeRetailPointAssignment>(); public DbSet<EmployeeRetailPointAssignment> EmployeeRetailPointAssignments => Set<EmployeeRetailPointAssignment>();
public DbSet<SuperAdminAuditLog> SuperAdminAuditLogs => Set<SuperAdminAuditLog>(); public DbSet<SuperAdminAuditLog> SuperAdminAuditLogs => Set<SuperAdminAuditLog>();
public DbSet<SystemSettings> SystemSettings => Set<SystemSettings>(); public DbSet<SystemSettings> SystemSettings => Set<SystemSettings>();
public DbSet<foodmarket.Domain.Platform.PlatformSettings> PlatformSettings => Set<foodmarket.Domain.Platform.PlatformSettings>();
protected override void OnModelCreating(ModelBuilder builder) protected override void OnModelCreating(ModelBuilder builder)
{ {
@ -86,6 +87,15 @@ protected override void OnModelCreating(ModelBuilder builder)
b.ToTable("system_settings"); b.ToTable("system_settings");
}); });
builder.Entity<foodmarket.Domain.Platform.PlatformSettings>(b =>
{
b.ToTable("platform_settings");
b.Property(x => x.SmtpHost).HasMaxLength(200);
b.Property(x => x.SmtpUsername).HasMaxLength(200);
b.Property(x => x.FromEmail).HasMaxLength(200);
b.Property(x => x.FromName).HasMaxLength(200);
});
builder.Entity<SuperAdminAuditLog>(b => builder.Entity<SuperAdminAuditLog>(b =>
{ {
b.ToTable("super_admin_audit_log"); b.ToTable("super_admin_audit_log");

View file

@ -0,0 +1,39 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <summary>Платформенные настройки (singleton). SMTP-креды для отправки
/// писем (forgot-password, нотификации). Управляется SuperAdmin'ом через
/// /super-admin/platform-settings. Видна только им.</summary>
public partial class Phase5b_PlatformSettings : Migration
{
protected override void Up(MigrationBuilder b)
{
b.CreateTable(
name: "platform_settings",
schema: "public",
columns: table => new
{
Id = table.Column<System.Guid>(type: "uuid", nullable: false),
SmtpHost = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
SmtpPort = table.Column<int>(type: "integer", nullable: true),
SmtpUseSsl = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
SmtpStartTls = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
SmtpUsername = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
SmtpPasswordEncrypted = table.Column<string>(type: "text", nullable: true),
FromEmail = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
FromName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
CreatedAt = table.Column<System.DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<System.DateTime>(type: "timestamp with time zone", nullable: true),
},
constraints: table => table.PrimaryKey("PK_platform_settings", x => x.Id));
}
protected override void Down(MigrationBuilder b)
{
b.DropTable(name: "platform_settings", schema: "public");
}
}
}