diff --git a/src/Gameboard.Api/Data/Entities/ChallengeSpec.cs b/src/Gameboard.Api/Data/Entities/ChallengeSpec.cs index eb1614e3..ccae60a0 100644 --- a/src/Gameboard.Api/Data/Entities/ChallengeSpec.cs +++ b/src/Gameboard.Api/Data/Entities/ChallengeSpec.cs @@ -28,8 +28,8 @@ public class ChallengeSpec : IEntity // nav properties public string GameId { get; set; } public Game Game { get; set; } - public ICollection Feedback { get; set; } = new List(); + public ICollection Feedback { get; set; } = []; public ICollection FeedbackSubmissions { get; set; } = []; - public ICollection Bonuses { get; set; } = new List(); - public ICollection PublishedPracticeCertificates { get; set; } = new List(); + public ICollection Bonuses { get; set; } = []; + public ICollection PublishedPracticeCertificates { get; set; } = []; } diff --git a/src/Gameboard.Api/Data/Entities/FeedbackSubmission.cs b/src/Gameboard.Api/Data/Entities/FeedbackSubmission.cs index 73aabe07..e113f73d 100644 --- a/src/Gameboard.Api/Data/Entities/FeedbackSubmission.cs +++ b/src/Gameboard.Api/Data/Entities/FeedbackSubmission.cs @@ -10,10 +10,10 @@ public enum FeedbackSubmissionAttachedEntityType Game = 1 } -public abstract class FeedbackSubmission : IEntity +public class FeedbackSubmission : IEntity { public string Id { get; set; } - public required FeedbackSubmissionAttachedEntityType AttachedEntityType { get; set; } + public FeedbackSubmissionAttachedEntityType AttachedEntityType { get; set; } public DateTimeOffset? WhenEdited { get; set; } public required DateTimeOffset WhenSubmitted { get; set; } @@ -23,19 +23,19 @@ public abstract class FeedbackSubmission : IEntity // navs public required string TeamId { get; set; } public required string FeedbackTemplateId { get; set; } - public required FeedbackTemplate FeedbackTemplate { get; set; } + public FeedbackTemplate FeedbackTemplate { get; set; } public required string UserId { get; set; } - public required Data.User User { get; set; } + public Data.User User { get; set; } } public class FeedbackSubmissionChallengeSpec : FeedbackSubmission, IEntity { public required string ChallengeSpecId { get; set; } - public required Data.ChallengeSpec ChallengeSpec { get; set; } + public Data.ChallengeSpec ChallengeSpec { get; set; } } public class FeedbackSubmissionGame : FeedbackSubmission, IEntity { public required string GameId { get; set; } - public required Data.Game Game { get; set; } + public Data.Game Game { get; set; } } diff --git a/src/Gameboard.Api/Features/Feedback/Feedback.cs b/src/Gameboard.Api/Features/Feedback/Feedback.cs index 2feca039..25940900 100644 --- a/src/Gameboard.Api/Features/Feedback/Feedback.cs +++ b/src/Gameboard.Api/Features/Feedback/Feedback.cs @@ -7,7 +7,7 @@ namespace Gameboard.Api.Features.Feedback; -public class FeedbackSubmission +public class FeedbackSubmissionLegacy { // UserId and PlayerId are set automatically when saved public string ChallengeId { get; set; } diff --git a/src/Gameboard.Api/Features/Feedback/FeedbackController.cs b/src/Gameboard.Api/Features/Feedback/FeedbackController.cs index 86285358..e0aec4d0 100644 --- a/src/Gameboard.Api/Features/Feedback/FeedbackController.cs +++ b/src/Gameboard.Api/Features/Feedback/FeedbackController.cs @@ -62,7 +62,7 @@ public async Task Retrieve([FromQuery] FeedbackSearchParams model) /// /// [HttpPut("submit")] - public async Task Submit([FromBody] FeedbackSubmission model) + public async Task Submit([FromBody] FeedbackSubmissionLegacy model) { await Authorize(FeedbackService.UserIsEnrolled(model.GameId, Actor.Id)); await Validate(model); @@ -109,4 +109,17 @@ public Task GetTemplateForGameOrChallengeSpec([FromRoute] [HttpGet("template")] public Task ListTemplates() => _mediator.Send(new ListFeedbackTemplatesQuery()); + + /// + /// Create a new feedback response. + /// + /// + /// + [HttpPost()] + public Task ResponseCreate([FromBody] UpsertFeedbackSubmissionRequest request) + { + var modelState = ModelState; + var things = modelState.ErrorCount; + return _mediator.Send(new UpsertFeedbackSubmissionCommand(request)); + } } diff --git a/src/Gameboard.Api/Features/Feedback/FeedbackMapper.cs b/src/Gameboard.Api/Features/Feedback/FeedbackMapper.cs index c094a308..a34aa27c 100644 --- a/src/Gameboard.Api/Features/Feedback/FeedbackMapper.cs +++ b/src/Gameboard.Api/Features/Feedback/FeedbackMapper.cs @@ -22,7 +22,7 @@ public FeedbackMapper() CreateMap(); - CreateMap() + CreateMap() .ForMember(d => d.Submitted, opt => opt.MapFrom(s => s.Submit)) .ForMember(d => d.Answers, opt => opt.MapFrom(s => JsonSerializer.Serialize(s.Questions, JsonOptions)) diff --git a/src/Gameboard.Api/Features/Feedback/FeedbackService.cs b/src/Gameboard.Api/Features/Feedback/FeedbackService.cs index 0decc3a6..626ab91d 100644 --- a/src/Gameboard.Api/Features/Feedback/FeedbackService.cs +++ b/src/Gameboard.Api/Features/Feedback/FeedbackService.cs @@ -10,6 +10,7 @@ using Gameboard.Api.Data; using Gameboard.Api.Features.Feedback; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Internal; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; @@ -92,6 +93,31 @@ public IEnumerable GetFeedbackQuestionStats(QuestionTemplate[] qu return questionStats; } + public async Task ResolveTemplate(FeedbackSubmissionAttachedEntityType type, string id, CancellationToken cancellationToken) + { + var gameId = id; + if (type == FeedbackSubmissionAttachedEntityType.ChallengeSpec) + { + gameId = await _store + .WithNoTracking() + .Where(s => s.Id == id) + .Select(s => s.GameId) + .SingleAsync(cancellationToken); + } + + var gameTemplates = await _store + .WithNoTracking() + .Where(g => g.Id == gameId) + .Select(g => new + { + Game = g.FeedbackTemplate, + Challenges = g.ChallengesFeedbackTemplate + }) + .SingleAsync(cancellationToken); + + return type == FeedbackSubmissionAttachedEntityType.Game ? gameTemplates.Game : gameTemplates.Challenges; + } + public async Task Retrieve(FeedbackSearchParams model, string actorId) { // for normal challenge and game feedback, we can just do simple lookups on the provided IDs. @@ -132,8 +158,12 @@ public IEnumerable GetFeedbackQuestionStats(QuestionTemplate[] qu return Mapper.Map(entity); } + public async Task ResolveExistingSubmission(string userId, string teamId, FeedbackSubmissionAttachedEntityType entityType, string entityId) + { + + } - public async Task Submit(Features.Feedback.FeedbackSubmission model, string actorId) + public async Task Submit(FeedbackSubmissionLegacy model, string actorId) { var lookup = MakeFeedbackLookup(model.GameId, model.ChallengeId, model.ChallengeSpecId, actorId); var entity = await LoadFeedback(lookup); diff --git a/src/Gameboard.Api/Features/Feedback/FeedbackValidator.cs b/src/Gameboard.Api/Features/Feedback/FeedbackValidator.cs index 7b8a22cf..3cb5b468 100644 --- a/src/Gameboard.Api/Features/Feedback/FeedbackValidator.cs +++ b/src/Gameboard.Api/Features/Feedback/FeedbackValidator.cs @@ -14,13 +14,13 @@ public class FeedbackValidator(IStore store) : IModelValidator public Task Validate(object model) { - if (model is Features.Feedback.FeedbackSubmission) - return _validate(model as Features.Feedback.FeedbackSubmission); + if (model is Features.Feedback.FeedbackSubmissionLegacy) + return _validate(model as Features.Feedback.FeedbackSubmissionLegacy); throw new System.NotImplementedException(); } - private async Task _validate(Features.Feedback.FeedbackSubmission model) + private async Task _validate(Features.Feedback.FeedbackSubmissionLegacy model) { if (!await _store.AnyAsync(g => g.Id == model.GameId, CancellationToken.None)) throw new ResourceNotFound(model.GameId); diff --git a/src/Gameboard.Api/Features/Feedback/Requests/UpsertFeedbackSubmission/UpsertFeedbackSubmission.cs b/src/Gameboard.Api/Features/Feedback/Requests/UpsertFeedbackSubmission/UpsertFeedbackSubmission.cs new file mode 100644 index 00000000..b1c87e86 --- /dev/null +++ b/src/Gameboard.Api/Features/Feedback/Requests/UpsertFeedbackSubmission/UpsertFeedbackSubmission.cs @@ -0,0 +1,153 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Gameboard.Api.Common.Services; +using Gameboard.Api.Data; +using Gameboard.Api.Features.Teams; +using Gameboard.Api.Services; +using Gameboard.Api.Structure.MediatR; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Gameboard.Api.Features.Feedback; + +public record UpsertFeedbackSubmissionCommand(UpsertFeedbackSubmissionRequest Request) : IRequest; + +// TODO: refactor this into separate commands for game/challenge that share an injected validator +internal sealed class UpsertFeedbackSubmissionHandler +( + IActingUserService actingUserService, + FeedbackService feedbackService, + INowService now, + IStore store, + ITeamService teamService, + IValidatorService validatorService +) : IRequestHandler +{ + private readonly IActingUserService _actingUserService = actingUserService; + private readonly FeedbackService _feedbackService = feedbackService; + private readonly INowService _nowService = now; + private readonly IStore _store = store; + private readonly ITeamService _teamService = teamService; + private readonly IValidatorService _validator = validatorService; + + public async Task Handle(UpsertFeedbackSubmissionCommand request, CancellationToken cancellationToken) + { + var actingUserId = _actingUserService.Get()?.Id; + + _validator + .Auth(c => c.RequireAuthentication()) + .AddValidator(ctx => + { + if (request.Request.AttachedEntity.EntityType != FeedbackSubmissionAttachedEntityType.ChallengeSpec && request.Request.AttachedEntity.EntityType != FeedbackSubmissionAttachedEntityType.Game) + { + ctx.AddValidationException(new InvalidParameterValue(nameof(request.Request.AttachedEntity.EntityType), "Must be either game or challengespec", request.Request.AttachedEntity.EntityType)); + } + }) + .AddValidator(async ctx => + { + if (!await _teamService.IsOnTeam(request.Request.AttachedEntity.TeamId, _actingUserService.Get().Id)) + { + ctx.AddValidationException(new UserIsntOnTeam(actingUserId, request.Request.AttachedEntity.TeamId, $"User isn't on the expected team.")); + } + }) + .AddValidator(async ctx => + { + var template = await _feedbackService.ResolveTemplate(request.Request.AttachedEntity.EntityType, request.Request.AttachedEntity.Id, cancellationToken); + + if (template is null || template.Id != request.Request.FeedbackTemplateId) + { + ctx.AddValidationException(new InvalidFeedbackTemplateId(request.Request.FeedbackTemplateId, request.Request.AttachedEntity.EntityType, request.Request.AttachedEntity.Id)); + } + + }) + .AddEntityExistsValidator(request.Request.FeedbackTemplateId); + + if (request.Request.AttachedEntity.EntityType == FeedbackSubmissionAttachedEntityType.ChallengeSpec) + { + _validator.AddEntityExistsValidator(request.Request.AttachedEntity.Id); + + if (request.Request.Id.IsNotEmpty()) + { + _validator.AddEntityExistsValidator(request.Request.Id); + } + } + else + { + _validator.AddEntityExistsValidator(request.Request.AttachedEntity.Id); + + if (request.Request.Id.IsNotEmpty()) + { + _validator.AddEntityExistsValidator(request.Request.Id); + } + } + + await _validator.Validate(cancellationToken); + + // if updating, update + if (request.Request.Id.IsNotEmpty()) + { + if (request.Request.AttachedEntity.EntityType == FeedbackSubmissionAttachedEntityType.ChallengeSpec) + { + var existingSubmission = await _store + .WithNoTracking() + .Where(s => s.Id == request.Request.Id) + .SingleAsync(cancellationToken); + + existingSubmission.WhenEdited = _nowService.Get(); + existingSubmission.Responses = [.. request.Request.Responses]; + await _store.SaveUpdate(existingSubmission, cancellationToken); + return new UpsertFeedbackSubmissionResponse { Submission = existingSubmission }; + } + else if (request.Request.AttachedEntity.EntityType == FeedbackSubmissionAttachedEntityType.Game) + { + var existingSubmission = await _store + .WithNoTracking() + .Where(s => s.Id == request.Request.Id) + .SingleAsync(cancellationToken); + + existingSubmission.WhenEdited = _nowService.Get(); + existingSubmission.Responses = [.. request.Request.Responses]; + await _store.SaveUpdate(existingSubmission, cancellationToken); + return new UpsertFeedbackSubmissionResponse { Submission = existingSubmission }; + } + } + else + { + // if creating, create + if (request.Request.AttachedEntity.EntityType == FeedbackSubmissionAttachedEntityType.ChallengeSpec) + { + var result = await _store + .Create(new() + { + ChallengeSpecId = request.Request.AttachedEntity.Id, + FeedbackTemplateId = request.Request.FeedbackTemplateId, + Responses = [.. request.Request.Responses], + TeamId = request.Request.AttachedEntity.TeamId, + UserId = actingUserId, + WhenSubmitted = _nowService.Get(), + }, cancellationToken); + + return new UpsertFeedbackSubmissionResponse { Submission = result }; + } + else + { + var result = await _store + .Create(new() + { + GameId = request.Request.AttachedEntity.Id, + FeedbackTemplateId = request.Request.FeedbackTemplateId, + Responses = [.. request.Request.Responses], + TeamId = request.Request.AttachedEntity.TeamId, + UserId = actingUserId, + WhenSubmitted = _nowService.Get(), + }, cancellationToken); + + return new UpsertFeedbackSubmissionResponse { Submission = result }; + } + } + + throw new NotImplementedException(); + } +} diff --git a/src/Gameboard.Api/Features/Feedback/Requests/UpsertFeedbackSubmission/UpsertFeedbackSubmissionModels.cs b/src/Gameboard.Api/Features/Feedback/Requests/UpsertFeedbackSubmission/UpsertFeedbackSubmissionModels.cs new file mode 100644 index 00000000..29969dc7 --- /dev/null +++ b/src/Gameboard.Api/Features/Feedback/Requests/UpsertFeedbackSubmission/UpsertFeedbackSubmissionModels.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; +using System.ComponentModel; +using System.Text.Json.Serialization; +using Gameboard.Api.Data; +using Gameboard.Api.Structure; + +namespace Gameboard.Api.Features.Feedback; + +public sealed class InvalidFeedbackTemplateId : GameboardValidationException +{ + public InvalidFeedbackTemplateId(string id, FeedbackSubmissionAttachedEntityType type, string entityId) + : base($"The template id {id} was not valid for {type} {entityId}") { } +} + +public sealed class UpsertFeedbackSubmissionRequest +{ + public string Id { get; set; } + public required UpsertFeedbackSubmissionRequestAttachedEntity AttachedEntity { get; set; } + public required string FeedbackTemplateId { get; set; } + public IEnumerable Responses { get; set; } +} + +public sealed class UpsertFeedbackSubmissionRequestAttachedEntity +{ + public required string Id { get; set; } + // [TypeConverter(typeof(JsonStringEnumConverter))] + public required FeedbackSubmissionAttachedEntityType EntityType { get; set; } + public required string TeamId { get; set; } +} + +public sealed class UpsertFeedbackSubmissionResponse +{ + public required FeedbackSubmission Submission { get; set; } +} diff --git a/src/Gameboard.Api/Structure/MediatR/Validators/ValidatorService.cs b/src/Gameboard.Api/Structure/MediatR/Validators/ValidatorService.cs index 15c55f10..c0ee83c8 100644 --- a/src/Gameboard.Api/Structure/MediatR/Validators/ValidatorService.cs +++ b/src/Gameboard.Api/Structure/MediatR/Validators/ValidatorService.cs @@ -4,12 +4,15 @@ using System.Threading; using System.Threading.Tasks; using Gameboard.Api.Common.Services; +using Gameboard.Api.Data; using Gameboard.Api.Structure.MediatR.Validators; +using Microsoft.EntityFrameworkCore; namespace Gameboard.Api.Structure.MediatR; public interface IValidatorService { + IValidatorService AddEntityExistsValidator(string id) where TEntity : class, IEntity; IValidatorService AddValidator(IGameboardValidator validator); IValidatorService AddValidator(Action validationAction); IValidatorService AddValidator(Func validationTask); @@ -19,12 +22,36 @@ public interface IValidatorService Task Validate(CancellationToken cancellationToken); } -internal class ValidatorService(IActingUserService actingUserService, UserRolePermissionsValidator userRolePermissionsValidator) : IValidatorService +internal class ValidatorService +( + IActingUserService actingUserService, + IStore store, + UserRolePermissionsValidator userRolePermissionsValidator +) : IValidatorService { private readonly IActingUserService _actingUserService = actingUserService; private readonly IList> _validationTasks = []; + private readonly IStore _store = store; private readonly UserRolePermissionsValidator _userRolePermissionsValidator = userRolePermissionsValidator; + public IValidatorService AddEntityExistsValidator(string id) where TEntity : class, IEntity + { + _validationTasks.Add(async ctx => + { + var exists = await _store + .WithNoTracking() + .Where(e => e.Id == id) + .AnyAsync(); + + if (!exists) + { + ctx.AddValidationException(new ResourceNotFound(id)); + } + }); + + return this; + } + public IValidatorService AddValidator(IGameboardValidator validator) { _validationTasks.Add(validator.GetValidationTask(default));