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>
170 lines
7.3 KiB
C#
170 lines
7.3 KiB
C#
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;
|
||
}
|
||
}
|
||
}
|