using System.Collections.Concurrent;
using System.Net.Http.Json;
using System.Security.Claims;
using foodmarket.Application.Common.Email;
using foodmarket.Application.Common.Tenancy;
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers;
/// Sprint 17: in-app feedback widget. POST /api/feedback
/// принимает {category, message}, отправляет:
/// - email на FromEmail из PlatformSettings (дубль администратору
/// платформы — пользователь не должен знать кому именно);
/// - Telegram-сообщение в чат поддержки, если задан
/// SupportTelegram:BotToken + SupportTelegram:ChatId.
///
/// Rate-limit: 5 отправок в час на одного user'а — in-memory dictionary
/// (для одного API-инстанса). При scale-out — переехать на Redis.
[ApiController]
[Authorize]
[Route("api/feedback")]
public class FeedbackController : ControllerBase
{
public enum FeedbackCategory
{
Bug = 0,
Suggestion = 1,
Question = 2,
}
public record FeedbackInput(FeedbackCategory Category, string Message);
// Sprint 17: in-memory rate-limit per-user. После Sprint 13 у нас уже
// есть централизованный rate-limiter, но он привязан к auth-endpoint'ам.
// Для остальных API делаем простой ConcurrentDictionary.
private static readonly ConcurrentDictionary> _attempts = new();
private static readonly TimeSpan _window = TimeSpan.FromHours(1);
private const int _maxPerWindow = 5;
private readonly AppDbContext _db;
private readonly IEmailSender _email;
private readonly ITenantContext _tenant;
private readonly IConfiguration _cfg;
private readonly IHttpClientFactory _httpFactory;
private readonly ILogger _log;
public FeedbackController(
AppDbContext db, IEmailSender email, ITenantContext tenant,
IConfiguration cfg, IHttpClientFactory httpFactory,
ILogger log)
{
_db = db; _email = email; _tenant = tenant;
_cfg = cfg; _httpFactory = httpFactory; _log = log;
}
[HttpPost]
public async Task Submit([FromBody] FeedbackInput input, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(input.Message))
return BadRequest(new { error = "Сообщение обязательно.", field = "message" });
if (input.Message.Length > 4000)
return BadRequest(new { error = "Сообщение слишком длинное (макс 4000 символов).", field = "message" });
// Rate-limit per-user.
var userKey = _tenant.UserId?.ToString() ?? HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
if (!CheckRateLimit(userKey))
{
_log.LogWarning("Feedback rate-limited for user={Key}", userKey);
return StatusCode(StatusCodes.Status429TooManyRequests, new
{
error = "Слишком много отзывов с вашего аккаунта (макс 5/час). Подождите час и попробуйте снова.",
});
}
// Контекст: кто отправил + откуда + категория + текст.
var email = User.FindFirstValue(System.Security.Claims.ClaimTypes.Email)
?? User.FindFirstValue("email") ?? "?";
var name = User.Identity?.Name ?? "?";
var org = "?";
if (_tenant.OrganizationId is { } orgId)
{
org = await _db.Organizations.IgnoreQueryFilters()
.Where(o => o.Id == orgId)
.Select(o => o.Name)
.FirstOrDefaultAsync(ct) ?? "?";
}
var ua = HttpContext.Request.Headers["User-Agent"].ToString();
var ip = HttpContext.Connection.RemoteIpAddress?.ToString();
var subject = $"Food Market feedback [{input.Category}] — {org}";
var body =
$"Категория: {input.Category}\n" +
$"Организация: {org}\n" +
$"Пользователь: {name} ({email})\n" +
$"IP: {ip}\n" +
$"User-Agent: {ua}\n" +
$"Время: {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC\n" +
$"\n---\n{input.Message}";
// Email — best-effort: ловим EmailNotConfigured отдельно (это NOT
// ошибка для пользователя — feedback всё равно попадёт в логи + Telegram).
var emailSent = false;
try
{
// Берём FromEmail из PlatformSettings как «адрес поддержки».
var settings = await _db.PlatformSettings.AsNoTracking().FirstOrDefaultAsync(ct);
var to = settings?.FromEmail;
if (!string.IsNullOrEmpty(to))
{
await _email.SendAsync(to, subject, body, ct);
emailSent = true;
}
}
catch (EmailNotConfiguredException ex)
{
_log.LogWarning("Feedback email skipped: {Reason}", ex.Message);
}
catch (Exception ex)
{
_log.LogError(ex, "Feedback email failed");
}
// Telegram — тоже best-effort.
var telegramSent = false;
try
{
var token = _cfg["SupportTelegram:BotToken"];
var chatId = _cfg["SupportTelegram:ChatId"];
if (!string.IsNullOrEmpty(token) && !string.IsNullOrEmpty(chatId))
{
using var http = _httpFactory.CreateClient();
var resp = await http.PostAsJsonAsync(
$"https://api.telegram.org/bot{token}/sendMessage",
new { chat_id = chatId, text = subject + "\n\n" + body }, ct);
telegramSent = resp.IsSuccessStatusCode;
if (!telegramSent)
_log.LogWarning("Telegram feedback returned {Status}", (int)resp.StatusCode);
}
}
catch (Exception ex)
{
_log.LogWarning(ex, "Feedback Telegram failed");
}
// Структурированный лог — гарантированно сохранится даже если оба
// канала упали (можно вытащить из Serilog позже).
_log.LogInformation(
"Feedback received: cat={Category} email={Email} org={Org} len={Length} sentEmail={E} sentTelegram={T}",
input.Category, email, org, input.Message.Length, emailSent, telegramSent);
return Ok(new { ok = true, deliveredEmail = emailSent, deliveredTelegram = telegramSent });
}
private static bool CheckRateLimit(string key)
{
var now = DateTime.UtcNow;
var attempts = _attempts.GetOrAdd(key, _ => new List());
lock (attempts)
{
attempts.RemoveAll(t => now - t > _window);
if (attempts.Count >= _maxPerWindow) return false;
attempts.Add(now);
return true;
}
}
}