Add general app feedback reporting
This commit is contained in:
@@ -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 ?? "";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user