Add general app feedback reporting

This commit is contained in:
hz
2026-07-15 20:55:13 -05:00
parent 98fa98b5b1
commit aec56f168e
17 changed files with 544 additions and 1 deletions
+15
View File
@@ -7,6 +7,21 @@ namespace QuestionsApi.Tests;
public sealed class CcsCaseTests
{
[Fact]
public void AppFeedbackValidationAcceptsSafeDiagnostics()
{
var request = new CreateAppFeedbackRequest {
Category = "bug", Details = "The next button stopped responding.",
Context = new AppFeedbackContext { Screen = "player", Platform = "Web", ViewportWidth = 1280, ViewportHeight = 720 }
};
Assert.Null(AppFeedbackValidator.Validate(request));
request.Category = "content_accuracy";
Assert.Equal("category is invalid", AppFeedbackValidator.Validate(request));
request.Category = "bug";
request.Details = "short";
Assert.Contains("details", AppFeedbackValidator.Validate(request));
}
[Fact]
public void CcsReportCategoriesAreSpecificAndStable()
{
@@ -0,0 +1,47 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using QuestionsApi.Models;
using QuestionsApi.Services;
using System.Security.Claims;
namespace QuestionsApi.Controllers;
[ApiController, Route("api/app-feedback")]
public sealed class AppFeedbackController(AppFeedbackStore feedback) : ControllerBase
{
private const int MaxReviewerNoteLength = 2000;
[HttpPost, Authorize(Roles = "USER,SUPER")]
public async Task<IActionResult> Create(CreateAppFeedbackRequest request)
{
if (AppFeedbackValidator.Validate(request) is { } error) return BadRequest(error);
var report = await feedback.CreateAsync(UserId(), request);
return StatusCode(StatusCodes.Status201Created, new { id = report.Id, report.Category, report.Status, report.CreatedUtc });
}
[HttpGet, Authorize(Roles = "SUPER")]
public async Task<IActionResult> List([FromQuery] string status = "open", [FromQuery] string category = "", [FromQuery] string userId = "", [FromQuery] int skip = 0, [FromQuery] int limit = 50)
{
status = status.Trim().ToLowerInvariant(); category = category.Trim().ToLowerInvariant(); userId = userId.Trim();
if (status != "all" && status != QuestionReportStatus.Open && !QuestionReportStatus.Terminal.Contains(status)) return BadRequest("status is invalid");
if (category.Length > 0 && !AppFeedbackCategory.All.Contains(category)) return BadRequest("category is invalid");
if (skip < 0 || limit is < 1 or > 100) return BadRequest("pagination is invalid");
return Ok(await feedback.SearchAsync(status, category, userId, skip, limit));
}
[HttpGet("{id}"), Authorize(Roles = "SUPER")]
public async Task<IActionResult> Get(string id) => await feedback.GetAsync(id.Trim()) is { } report ? Ok(report) : NotFound();
[HttpPatch("{id}/status"), Authorize(Roles = "SUPER")]
public async Task<IActionResult> Close(string id, UpdateQuestionReportStatusRequest request)
{
var status = (request.Status ?? "").Trim().ToLowerInvariant();
var note = (request.ReviewerNote ?? "").Trim();
if (!QuestionReportStatus.Terminal.Contains(status)) return BadRequest("status must be resolved or dismissed");
if (note.Length > MaxReviewerNoteLength) return BadRequest($"reviewerNote must be {MaxReviewerNoteLength} characters or fewer");
var updated = await feedback.CloseAsync(id.Trim(), status, UserId(), note);
return updated is null ? Conflict("feedback is missing or already closed") : Ok(updated);
}
private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue(ClaimTypes.Name) ?? User.Identity?.Name ?? "";
}
+78
View File
@@ -0,0 +1,78 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace QuestionsApi.Models;
public static class AppFeedbackCategory
{
public const string Bug = "bug";
public const string Usability = "usability";
public const string FeatureRequest = "feature_request";
public const string Account = "account";
public const string Other = "other";
public static readonly HashSet<string> All = [Bug, Usability, FeatureRequest, Account, Other];
}
public sealed class AppFeedbackContext
{
public string Screen { get; set; } = "";
public string SessionType { get; set; } = "";
public string Platform { get; set; } = "";
public int ViewportWidth { get; set; }
public int ViewportHeight { get; set; }
public string AppVersion { get; set; } = "";
public string BuildRevision { get; set; } = "";
public DateTime? ClientUtc { get; set; }
}
public sealed class AppFeedback
{
[BsonId, BsonRepresentation(BsonType.ObjectId)] public string? Id { get; set; }
public string UserId { get; set; } = "";
public string Category { get; set; } = "";
public string Details { get; set; } = "";
public AppFeedbackContext Context { get; set; } = new();
public string Status { get; set; } = QuestionReportStatus.Open;
public string ReviewedByUserId { get; set; } = "";
public string ReviewerNote { get; set; } = "";
public DateTime? ReviewedUtc { get; set; }
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
}
public sealed class CreateAppFeedbackRequest
{
public string Category { get; set; } = "";
public string Details { get; set; } = "";
public AppFeedbackContext Context { get; set; } = new();
}
public sealed class AppFeedbackListResponse
{
public List<AppFeedback> Items { get; set; } = [];
public long Total { get; set; }
public int Skip { get; set; }
public int Limit { get; set; }
}
public static class AppFeedbackValidator
{
public const int MinDetailsLength = 10;
public const int MaxDetailsLength = 4000;
public const int MaxContextLength = 120;
public static string? Validate(CreateAppFeedbackRequest request)
{
var category = (request.Category ?? "").Trim().ToLowerInvariant();
var details = (request.Details ?? "").Trim();
if (!AppFeedbackCategory.All.Contains(category)) return "category is invalid";
if (details.Length is < MinDetailsLength or > MaxDetailsLength)
return $"details must be between {MinDetailsLength} and {MaxDetailsLength} characters";
var context = request.Context ?? new AppFeedbackContext();
if (context.ViewportWidth < 0 || context.ViewportHeight < 0) return "viewport dimensions cannot be negative";
if (new[] { context.Screen, context.SessionType, context.Platform, context.AppVersion, context.BuildRevision }
.Any(value => (value ?? "").Length > MaxContextLength))
return $"context values must be {MaxContextLength} characters or fewer";
return null;
}
}
+1
View File
@@ -20,6 +20,7 @@ builder.Services.AddSingleton<ExamSimulationStore>();
builder.Services.AddSingleton<CcsStore>();
builder.Services.AddSingleton<CcsRatingStore>();
builder.Services.AddSingleton<CcsCaseReportStore>();
builder.Services.AddSingleton<AppFeedbackStore>();
builder.Services.AddSingleton<ServiceMetrics>();
builder.Services.AddHostedService<AnswerStatisticsMaintenanceService>();
+4
View File
@@ -18,6 +18,10 @@ STEP-style question bank service for study games and clients.
- `GET /api/question-reports` List and filter reports, newest first (SUPER only).
- `GET /api/question-reports/{id}` Get a report and its question snapshot (SUPER only).
- `PATCH /api/question-reports/{id}/status` Resolve or dismiss an open report (SUPER only).
- `POST /api/app-feedback` Submit general feedback with safe client context (USER or SUPER).
- `GET /api/app-feedback` List and filter app feedback, newest first (SUPER only).
- `GET /api/app-feedback/{id}` Get one app feedback report (SUPER only).
- `PATCH /api/app-feedback/{id}/status` Resolve or dismiss app feedback (SUPER only).
Player reads require `USER` or `SUPER` JWT auth. Authoring endpoints require `SUPER`.
@@ -0,0 +1,56 @@
using MongoDB.Bson;
using MongoDB.Driver;
using QuestionsApi.Models;
namespace QuestionsApi.Services;
public sealed class AppFeedbackStore
{
private readonly IMongoCollection<AppFeedback> _feedback;
public AppFeedbackStore(MongoDatabaseProvider mongo)
{
_feedback = mongo.Database.GetCollection<AppFeedback>("AppFeedback");
_feedback.Indexes.CreateMany([
new CreateIndexModel<AppFeedback>(Builders<AppFeedback>.IndexKeys.Ascending(value => value.Status).Descending(value => value.CreatedUtc)),
new CreateIndexModel<AppFeedback>(Builders<AppFeedback>.IndexKeys.Ascending(value => value.Category).Descending(value => value.CreatedUtc)),
new CreateIndexModel<AppFeedback>(Builders<AppFeedback>.IndexKeys.Ascending(value => value.UserId).Descending(value => value.CreatedUtc))
]);
}
public async Task<AppFeedback> CreateAsync(string userId, CreateAppFeedbackRequest request)
{
var now = DateTime.UtcNow;
var report = new AppFeedback {
UserId = userId, Category = request.Category.Trim().ToLowerInvariant(), Details = request.Details.Trim(),
Context = request.Context ?? new AppFeedbackContext(), CreatedUtc = now, UpdatedUtc = now
};
await _feedback.InsertOneAsync(report);
return report;
}
public async Task<AppFeedback?> GetAsync(string id) => ObjectId.TryParse(id, out _)
? await _feedback.Find(value => value.Id == id).FirstOrDefaultAsync() : null;
public async Task<AppFeedbackListResponse> SearchAsync(string status, string category, string userId, int skip, int limit)
{
var filter = Builders<AppFeedback>.Filter.Empty;
if (status != "all") filter &= Builders<AppFeedback>.Filter.Eq(value => value.Status, status);
if (category.Length > 0) filter &= Builders<AppFeedback>.Filter.Eq(value => value.Category, category);
if (userId.Length > 0) filter &= Builders<AppFeedback>.Filter.Eq(value => value.UserId, userId);
var total = await _feedback.CountDocumentsAsync(filter);
var items = await _feedback.Find(filter).SortByDescending(value => value.CreatedUtc).Skip(skip).Limit(limit).ToListAsync();
return new AppFeedbackListResponse { Items = items, Total = total, Skip = skip, Limit = limit };
}
public async Task<AppFeedback?> CloseAsync(string id, string status, string reviewer, string note)
{
if (!ObjectId.TryParse(id, out _)) return null;
var now = DateTime.UtcNow;
return await _feedback.FindOneAndUpdateAsync(
value => value.Id == id && value.Status == QuestionReportStatus.Open,
Builders<AppFeedback>.Update.Set(value => value.Status, status).Set(value => value.ReviewerNote, note)
.Set(value => value.ReviewedByUserId, reviewer).Set(value => value.ReviewedUtc, now).Set(value => value.UpdatedUtc, now),
new FindOneAndUpdateOptions<AppFeedback> { ReturnDocument = ReturnDocument.After });
}
}