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; } } }