using System.Security.Claims;
using foodmarket.Application.Common.Email;
using foodmarket.Domain.Organizations;
using foodmarket.Domain.Platform;
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers.SuperAdmin;
/// SuperAdmin: единая платформенная настройка SMTP. GET отдаёт всё
/// КРОМЕ пароля (только has-password флаг). PUT принимает все поля + опционально
/// новый пароль. Пароль шифруется через DataProtection (purpose="foodmarket.smtp")
/// перед записью в БД. Все мутации пишутся в SuperAdminAuditLog с reason.
[ApiController]
[Authorize(Roles = "SuperAdmin")]
[Route("api/super-admin/platform-settings")]
public class PlatformSettingsController : ControllerBase
{
private readonly AppDbContext _db;
private readonly IDataProtectionProvider _dpProvider;
private readonly IEmailSender _email;
public PlatformSettingsController(AppDbContext db, IDataProtectionProvider dpProvider, IEmailSender email)
{
_db = db; _dpProvider = dpProvider; _email = email;
}
public record PlatformSettingsDto(
string? SmtpHost, int? SmtpPort,
bool SmtpUseSsl, bool SmtpStartTls,
string? SmtpUsername, bool HasSmtpPassword,
string? FromEmail, string? FromName,
DateTime? UpdatedAt);
public record PlatformSettingsInput(
string Reason,
string? SmtpHost, int? SmtpPort,
bool SmtpUseSsl, bool SmtpStartTls,
string? SmtpUsername,
// Если null/пусто — пароль не меняется. Если задан — шифруется и
// записывается. Чтобы СНЯТЬ пароль (отправлять без auth), используем
// спец-значение "__clear__".
string? NewSmtpPassword,
string? FromEmail, string? FromName);
public record TestSendInput(string ToEmail, string Subject, string Body);
[HttpGet]
public async Task> Get(CancellationToken ct)
{
var s = await GetOrCreateAsync(ct);
return new PlatformSettingsDto(
s.SmtpHost, s.SmtpPort,
s.SmtpUseSsl, s.SmtpStartTls,
s.SmtpUsername, !string.IsNullOrEmpty(s.SmtpPasswordEncrypted),
s.FromEmail, s.FromName,
s.UpdatedAt);
}
[HttpPut]
public async Task Update([FromBody] PlatformSettingsInput input, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(input.Reason) || input.Reason.Trim().Length < 10)
return BadRequest(new { error = "Причина изменения обязательна (≥ 10 символов) — она пишется в журнал." });
var s = await GetOrCreateAsync(ct);
var prev = new
{
s.SmtpHost, s.SmtpPort, s.SmtpUseSsl, s.SmtpStartTls,
s.SmtpUsername, s.FromEmail, s.FromName,
HasPassword = !string.IsNullOrEmpty(s.SmtpPasswordEncrypted),
};
s.SmtpHost = input.SmtpHost?.Trim();
s.SmtpPort = input.SmtpPort;
s.SmtpUseSsl = input.SmtpUseSsl;
s.SmtpStartTls = input.SmtpStartTls;
s.SmtpUsername = input.SmtpUsername?.Trim();
s.FromEmail = input.FromEmail?.Trim();
s.FromName = input.FromName?.Trim();
s.UpdatedAt = DateTime.UtcNow;
if (input.NewSmtpPassword == "__clear__")
{
s.SmtpPasswordEncrypted = null;
}
else if (!string.IsNullOrEmpty(input.NewSmtpPassword))
{
var protector = _dpProvider.CreateProtector("foodmarket.smtp");
s.SmtpPasswordEncrypted = protector.Protect(input.NewSmtpPassword);
}
await _db.SaveChangesAsync(ct);
await LogAsync("PlatformSettingsUpdate",
$"Обновлены SMTP-настройки платформы (host={s.SmtpHost} from={s.FromEmail})",
input.Reason,
System.Text.Json.JsonSerializer.Serialize(new { prev, next = new {
s.SmtpHost, s.SmtpPort, s.SmtpUseSsl, s.SmtpStartTls,
s.SmtpUsername, s.FromEmail, s.FromName,
HasPassword = !string.IsNullOrEmpty(s.SmtpPasswordEncrypted),
} }), ct);
return NoContent();
}
[HttpPost("test-send")]
public async Task TestSend([FromBody] TestSendInput input, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(input.ToEmail))
return BadRequest(new { error = "Адрес получателя обязателен." });
try
{
await _email.SendAsync(input.ToEmail.Trim(),
string.IsNullOrWhiteSpace(input.Subject) ? "ALL MARKET — тестовое сообщение" : input.Subject,
string.IsNullOrWhiteSpace(input.Body) ? "Тестовое письмо от ALL MARKET." : input.Body,
ct);
return Ok(new { ok = true, sentTo = input.ToEmail });
}
catch (EmailNotConfiguredException ex)
{
return BadRequest(new { ok = false, error = ex.Message });
}
catch (Exception ex)
{
// Полный текст ошибки SMTP пишем в response — это SuperAdmin-only,
// diagnostic-info ему нужна.
return StatusCode(StatusCodes.Status500InternalServerError, new { ok = false, error = ex.Message });
}
}
private async Task GetOrCreateAsync(CancellationToken ct)
{
var s = await _db.PlatformSettings.FirstOrDefaultAsync(ct);
if (s is null)
{
s = new PlatformSettings();
_db.PlatformSettings.Add(s);
await _db.SaveChangesAsync(ct);
}
return s;
}
private async Task LogAsync(string actionType, string description, string? reason, string changesJson, CancellationToken ct)
{
var userIdRaw = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
Guid.TryParse(userIdRaw, out var uid);
_db.SuperAdminAuditLogs.Add(new SuperAdminAuditLog
{
SuperAdminUserId = uid,
ActionType = actionType,
OrganizationId = null,
Description = description, Reason = reason,
ChangesJson = changesJson,
IpAddress = HttpContext?.Connection?.RemoteIpAddress?.ToString() ?? "",
});
await _db.SaveChangesAsync(ct);
}
}