Skip to content

Commit

Permalink
More feedback wip
Browse files Browse the repository at this point in the history
  • Loading branch information
sei-bstein committed Dec 10, 2024
1 parent 2f8ea6d commit cad4d5b
Show file tree
Hide file tree
Showing 10 changed files with 274 additions and 17 deletions.
6 changes: 3 additions & 3 deletions src/Gameboard.Api/Data/Entities/ChallengeSpec.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ public class ChallengeSpec : IEntity
// nav properties
public string GameId { get; set; }
public Game Game { get; set; }
public ICollection<Feedback> Feedback { get; set; } = new List<Feedback>();
public ICollection<Feedback> Feedback { get; set; } = [];
public ICollection<FeedbackSubmissionChallengeSpec> FeedbackSubmissions { get; set; } = [];
public ICollection<ChallengeBonus> Bonuses { get; set; } = new List<ChallengeBonus>();
public ICollection<PublishedPracticeCertificate> PublishedPracticeCertificates { get; set; } = new List<PublishedPracticeCertificate>();
public ICollection<ChallengeBonus> Bonuses { get; set; } = [];
public ICollection<PublishedPracticeCertificate> PublishedPracticeCertificates { get; set; } = [];
}
12 changes: 6 additions & 6 deletions src/Gameboard.Api/Data/Entities/FeedbackSubmission.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }

Expand All @@ -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; }
}
2 changes: 1 addition & 1 deletion src/Gameboard.Api/Features/Feedback/Feedback.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
15 changes: 14 additions & 1 deletion src/Gameboard.Api/Features/Feedback/FeedbackController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ public async Task<Feedback> Retrieve([FromQuery] FeedbackSearchParams model)
/// <param name="model"></param>
/// <returns></returns>
[HttpPut("submit")]
public async Task<Feedback> Submit([FromBody] FeedbackSubmission model)
public async Task<Feedback> Submit([FromBody] FeedbackSubmissionLegacy model)
{
await Authorize(FeedbackService.UserIsEnrolled(model.GameId, Actor.Id));
await Validate(model);
Expand Down Expand Up @@ -109,4 +109,17 @@ public Task<FeedbackTemplateView> GetTemplateForGameOrChallengeSpec([FromRoute]
[HttpGet("template")]
public Task<ListFeedbackTemplatesResponse> ListTemplates()
=> _mediator.Send(new ListFeedbackTemplatesQuery());

/// <summary>
/// Create a new feedback response.
/// </summary>
/// <param name="request"></param>
/// <returns></returns>
[HttpPost()]
public Task<UpsertFeedbackSubmissionResponse> ResponseCreate([FromBody] UpsertFeedbackSubmissionRequest request)
{
var modelState = ModelState;
var things = modelState.ErrorCount;
return _mediator.Send(new UpsertFeedbackSubmissionCommand(request));
}
}
2 changes: 1 addition & 1 deletion src/Gameboard.Api/Features/Feedback/FeedbackMapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ public FeedbackMapper()

CreateMap<Feedback, Data.Feedback>();

CreateMap<FeedbackSubmission, Data.Feedback>()
CreateMap<FeedbackSubmissionLegacy, Data.Feedback>()
.ForMember(d => d.Submitted, opt => opt.MapFrom(s => s.Submit))
.ForMember(d => d.Answers, opt => opt.MapFrom(s =>
JsonSerializer.Serialize(s.Questions, JsonOptions))
Expand Down
32 changes: 31 additions & 1 deletion src/Gameboard.Api/Features/Feedback/FeedbackService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -92,6 +93,31 @@ public IEnumerable<QuestionStats> GetFeedbackQuestionStats(QuestionTemplate[] qu
return questionStats;
}

public async Task<FeedbackTemplate> ResolveTemplate(FeedbackSubmissionAttachedEntityType type, string id, CancellationToken cancellationToken)
{
var gameId = id;
if (type == FeedbackSubmissionAttachedEntityType.ChallengeSpec)
{
gameId = await _store
.WithNoTracking<Data.ChallengeSpec>()
.Where(s => s.Id == id)
.Select(s => s.GameId)
.SingleAsync(cancellationToken);
}

var gameTemplates = await _store
.WithNoTracking<Data.Game>()
.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<Features.Feedback.Feedback> Retrieve(FeedbackSearchParams model, string actorId)
{
// for normal challenge and game feedback, we can just do simple lookups on the provided IDs.
Expand Down Expand Up @@ -132,8 +158,12 @@ public IEnumerable<QuestionStats> GetFeedbackQuestionStats(QuestionTemplate[] qu
return Mapper.Map<Features.Feedback.Feedback>(entity);
}

public async Task<FeedbackSubmission> ResolveExistingSubmission(string userId, string teamId, FeedbackSubmissionAttachedEntityType entityType, string entityId)

Check warning on line 161 in src/Gameboard.Api/Features/Feedback/FeedbackService.cs

View workflow job for this annotation

GitHub Actions / test

This async method lacks 'await' operators and will run synchronously. Consider using the 'await' operator to await non-blocking API calls, or 'await Task.Run(...)' to do CPU-bound work on a background thread.

Check failure on line 161 in src/Gameboard.Api/Features/Feedback/FeedbackService.cs

View workflow job for this annotation

GitHub Actions / test

'FeedbackService.ResolveExistingSubmission(string, string, FeedbackSubmissionAttachedEntityType, string)': not all code paths return a value

Check warning on line 161 in src/Gameboard.Api/Features/Feedback/FeedbackService.cs

View workflow job for this annotation

GitHub Actions / test

This async method lacks 'await' operators and will run synchronously. Consider using the 'await' operator to await non-blocking API calls, or 'await Task.Run(...)' to do CPU-bound work on a background thread.

Check failure on line 161 in src/Gameboard.Api/Features/Feedback/FeedbackService.cs

View workflow job for this annotation

GitHub Actions / test

'FeedbackService.ResolveExistingSubmission(string, string, FeedbackSubmissionAttachedEntityType, string)': not all code paths return a value
{

}

public async Task<Features.Feedback.Feedback> Submit(Features.Feedback.FeedbackSubmission model, string actorId)
public async Task<Features.Feedback.Feedback> Submit(FeedbackSubmissionLegacy model, string actorId)
{
var lookup = MakeFeedbackLookup(model.GameId, model.ChallengeId, model.ChallengeSpecId, actorId);
var entity = await LoadFeedback(lookup);
Expand Down
6 changes: 3 additions & 3 deletions src/Gameboard.Api/Features/Feedback/FeedbackValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Data.Game>(g => g.Id == model.GameId, CancellationToken.None))
throw new ResourceNotFound<Data.Game>(model.GameId);
Expand Down
Original file line number Diff line number Diff line change
@@ -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<UpsertFeedbackSubmissionResponse>;

// 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<UpsertFeedbackSubmissionCommand, UpsertFeedbackSubmissionResponse>
{
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<UpsertFeedbackSubmissionResponse> 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<FeedbackSubmissionAttachedEntityType>(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<FeedbackTemplate>(request.Request.FeedbackTemplateId);

if (request.Request.AttachedEntity.EntityType == FeedbackSubmissionAttachedEntityType.ChallengeSpec)
{
_validator.AddEntityExistsValidator<Data.ChallengeSpec>(request.Request.AttachedEntity.Id);

if (request.Request.Id.IsNotEmpty())
{
_validator.AddEntityExistsValidator<FeedbackSubmissionChallengeSpec>(request.Request.Id);
}
}
else
{
_validator.AddEntityExistsValidator<Data.Game>(request.Request.AttachedEntity.Id);

if (request.Request.Id.IsNotEmpty())
{
_validator.AddEntityExistsValidator<FeedbackSubmissionGame>(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<FeedbackSubmissionChallengeSpec>()
.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<FeedbackSubmissionGame>()
.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<FeedbackSubmissionChallengeSpec>(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<FeedbackSubmissionGame>(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();
}
}
Original file line number Diff line number Diff line change
@@ -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<QuestionSubmission> Responses { get; set; }
}

public sealed class UpsertFeedbackSubmissionRequestAttachedEntity
{
public required string Id { get; set; }
// [TypeConverter(typeof(JsonStringEnumConverter<FeedbackSubmissionAttachedEntityType>))]
public required FeedbackSubmissionAttachedEntityType EntityType { get; set; }
public required string TeamId { get; set; }
}

public sealed class UpsertFeedbackSubmissionResponse
{
public required FeedbackSubmission Submission { get; set; }
}
Loading

0 comments on commit cad4d5b

Please sign in to comment.