food-market/src/food-market.api/Controllers/FeedbackController.cs
nns f56c6efab1 feat(s17): onboarding wizard + help kb + feedback + diagnostic + whats-new
Sprint 17 — onboarding-контур: 4-шаг wizard, контекстный help, in-app
feedback, admin self-diagnostic, /whats-new из CHANGELOG.md.

Ключевые цифры:
- Wizard: 4 шага + skip каждого, 7 e2e тестов ✓ за 20 секунд.
- Diagnostic: 7 параллельных проверок, ~80ms на stage.
- Bundle impact: initial +4 KB gzip (только FeedbackWidget +
  HelpTooltip + EmptyStateWithDemo в основном bundle; страницы lazy).
- Regression-suite: 35 → 42 flows + 60 → 66 visual snapshots.

Backend (новые endpoint'ы):
- /api/admin/diagnostic/run — 7 параллельных проверок (DB, SMTP,
  MinIO, Hangfire, диск, сертификаты, бэкап). Task.WhenAll, ~80ms.
- /api/feedback — POST {category, message}, email на FromEmail +
  Telegram (если SupportTelegram:* настроены). Rate-limit 5/час.
- /api/whats-new — парсер CHANGELOG.md, возвращает {buildVersion,
  items}. Dockerfile.api копирует CHANGELOG.md в content-root +
  пишет VERSION из GIT_SHA build-arg.

Frontend:
- /onboarding-wizard — 4-step builder, состояние в useState,
  localStorage.fm.wizardCompleted после завершения.
- <HelpTooltip topic="key"/> — popover на каждой странице, mapping
  src/lib/help-topics.ts (13 keys).
- /help — knowledge base, 7 markdown topics через import.meta.glob,
  mini-renderer без heavy deps, fuzzy search.
- /whats-new — список из /api/whats-new, иконки по типу (feat/fix).
- /admin/diagnostic — Admin/SuperAdmin only, 🟢/🟡/🔴 индикаторы.
- <FeedbackWidget> в sidebar footer + ссылки на /help и /whats-new.
- <EmptyStateWithDemo> placeholder для будущих видео-демо.

scripts/generate-changelog.sh — git log feat:/fix: за 90 дней
→ CHANGELOG.md (307 строк сгенерировано).

Wizard UX-screenshots в docs/sprint17-screenshots/ (6 PNG: 4 шага +
help + diagnostic).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 17:04:26 +05:00

170 lines
7.3 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
/// <summary>Sprint 17: in-app feedback widget. POST /api/feedback
/// принимает {category, message}, отправляет:
/// - email на FromEmail из PlatformSettings (дубль администратору
/// платформы — пользователь не должен знать кому именно);
/// - Telegram-сообщение в чат поддержки, если задан
/// <c>SupportTelegram:BotToken</c> + <c>SupportTelegram:ChatId</c>.
///
/// Rate-limit: 5 отправок в час на одного user'а — in-memory dictionary
/// (для одного API-инстанса). При scale-out — переехать на Redis.</summary>
[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<string, List<DateTime>> _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<FeedbackController> _log;
public FeedbackController(
AppDbContext db, IEmailSender email, ITenantContext tenant,
IConfiguration cfg, IHttpClientFactory httpFactory,
ILogger<FeedbackController> log)
{
_db = db; _email = email; _tenant = tenant;
_cfg = cfg; _httpFactory = httpFactory; _log = log;
}
[HttpPost]
public async Task<IActionResult> 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<DateTime>());
lock (attempts)
{
attempts.RemoveAll(t => now - t > _window);
if (attempts.Count >= _maxPerWindow) return false;
attempts.Add(now);
return true;
}
}
}