diff --git a/src/Gameboard.Api/Data/Store/Store.cs b/src/Gameboard.Api/Data/Store/Store.cs index 144b86f1..884ea015 100644 --- a/src/Gameboard.Api/Data/Store/Store.cs +++ b/src/Gameboard.Api/Data/Store/Store.cs @@ -33,6 +33,7 @@ Expression, SetPropertyCalls>> setProper Task> SaveAddRange(params TEntity[] entities) where TEntity : class, IEntity; Task SaveRemoveRange(params TEntity[] entities) where TEntity : class, IEntity; Task SaveUpdate(TEntity entity, CancellationToken cancellationToken) where TEntity : class, IEntity; + Task SaveUpdate(TEntity entity, Expression> property, CancellationToken cancellationToken) where TEntity : class, IEntity; Task SaveUpdateRange(params TEntity[] entities) where TEntity : class, IEntity; Task SingleAsync(string id, CancellationToken cancellationToken) where TEntity : class, IEntity; Task SingleOrDefaultAsync(CancellationToken cancellationToken) where TEntity : class, IEntity; @@ -169,6 +170,24 @@ public async Task SaveUpdate(TEntity entity, CancellationToken return entity; } + public async Task SaveUpdate(TEntity entity, Expression> property, CancellationToken cancellationToken) where TEntity : class, IEntity + { + if (_dbContext.Entry(entity).State == EntityState.Detached) + { + _dbContext.Attach(entity); + } + + if (property is not null) + { + _dbContext.Entry(entity).Property(property).IsModified = true; + } + + _dbContext.Update(entity); + await _dbContext.SaveChangesAsync(cancellationToken); + + return entity; + } + public async Task SaveUpdateRange(params TEntity[] entities) where TEntity : class, IEntity { foreach (var entity in entities) diff --git a/src/Gameboard.Api/Features/Feedback/Requests/UpsertFeedbackSubmission/UpsertFeedbackSubmission.cs b/src/Gameboard.Api/Features/Feedback/Requests/UpsertFeedbackSubmission/UpsertFeedbackSubmission.cs index 94754655..3771b0f5 100644 --- a/src/Gameboard.Api/Features/Feedback/Requests/UpsertFeedbackSubmission/UpsertFeedbackSubmission.cs +++ b/src/Gameboard.Api/Features/Feedback/Requests/UpsertFeedbackSubmission/UpsertFeedbackSubmission.cs @@ -83,49 +83,10 @@ await _validator // ultimately our retval var submissionModel = default(FeedbackSubmission); - // we also need the template so we can be sure to save an answer for every question, even if not supplied previously - var template = await _store - .WithNoTracking() - .Where(t => t.Id == request.Request.FeedbackTemplateId) - .SingleAsync(cancellationToken); - // if updating, update if (existingSubmission is not null) { - if (request.Request.AttachedEntity.EntityType == FeedbackSubmissionAttachedEntityType.ChallengeSpec) - { - submissionModel = await _store - .WithNoTracking() - .Where(s => s.Id == existingSubmission.Id) - .SingleAsync(cancellationToken); - } - else if (request.Request.AttachedEntity.EntityType == FeedbackSubmissionAttachedEntityType.Game) - { - submissionModel = await _store - .WithNoTracking() - .Where(s => s.Id == existingSubmission.Id) - .SingleAsync(cancellationToken); - } - - submissionModel.WhenEdited = _nowService.Get(); - submissionModel.Responses.Clear(); - - foreach (var question in _feedbackService.BuildQuestionConfigFromTemplate(template).Questions) - { - submissionModel.Responses.Add(new QuestionSubmission - { - Id = question.Prompt, - Answer = submissionModel.Responses.SingleOrDefault(r => r.Id == question.Id)?.Answer, - Prompt = question.Prompt, - ShortName = question.ShortName, - }); - } - - if (request.Request.IsFinalized && submissionModel.WhenFinalized is null) - { - submissionModel.WhenFinalized = _nowService.Get(); - } - submissionModel = await _store.SaveUpdate(submissionModel, cancellationToken); + submissionModel = await UpdateSubmission(existingSubmission, request.Request, cancellationToken); } else { @@ -169,4 +130,64 @@ await _validator cancellationToken ); } + + private async Task UpdateSubmission(FeedbackSubmissionView existingSubmission, UpsertFeedbackSubmissionRequest request, CancellationToken cancellationToken) + { + var submissionModel = default(FeedbackSubmission); + + // we also need the template so we can be sure to save an answer for every question, even if not supplied previously + var template = await _store + .WithNoTracking() + .Where(t => t.Id == request.FeedbackTemplateId) + .SingleAsync(cancellationToken); + + await _store.DoTransaction(async dbContext => + { + if (request.AttachedEntity.EntityType == FeedbackSubmissionAttachedEntityType.ChallengeSpec) + { + submissionModel = await _store + .WithTracking() + .Where(s => s.Id == existingSubmission.Id) + .SingleAsync(cancellationToken); + } + else if (request.AttachedEntity.EntityType == FeedbackSubmissionAttachedEntityType.Game) + { + submissionModel = await _store + .WithTracking() + .Where(s => s.Id == existingSubmission.Id) + .SingleAsync(cancellationToken); + } + + submissionModel.WhenEdited = _nowService.Get(); + + foreach (var question in _feedbackService.BuildQuestionConfigFromTemplate(template).Questions) + { + var existingResponse = submissionModel.Responses.SingleOrDefault(r => r.Id == question.Id); + if (existingResponse is not null) + { + existingResponse.Answer = request.Responses.SingleOrDefault(r => r.Id == question.Id)?.Answer; + dbContext.Entry(existingResponse).Property(r => r.Answer).IsModified = true; + } + else + { + submissionModel.Responses.Add(new QuestionSubmission + { + Id = question.Prompt, + Answer = request.Responses.SingleOrDefault(r => r.Id == question.Id)?.Answer, + Prompt = question.Prompt, + ShortName = question.ShortName, + }); + } + } + + if (request.IsFinalized && submissionModel.WhenFinalized is null) + { + submissionModel.WhenFinalized = _nowService.Get(); + } + + await dbContext.SaveChangesAsync(cancellationToken); + }, cancellationToken); + + return submissionModel; + } } diff --git a/src/Gameboard.Api/Features/Reports/Queries/FeedbackReport/FeedbackReportExport.cs b/src/Gameboard.Api/Features/Reports/Queries/FeedbackReport/FeedbackReportExport.cs index 51b23e22..62d94280 100644 --- a/src/Gameboard.Api/Features/Reports/Queries/FeedbackReport/FeedbackReportExport.cs +++ b/src/Gameboard.Api/Features/Reports/Queries/FeedbackReport/FeedbackReportExport.cs @@ -1,5 +1,5 @@ using System.Collections.Generic; -using System.Linq; +using System.Dynamic; using System.Threading; using System.Threading.Tasks; using Gameboard.Api.Features.Users; @@ -8,18 +8,18 @@ namespace Gameboard.Api.Features.Reports; -public record FeedbackReportExportQuery(FeedbackReportParameters Parameters) : IRequest>; +public record FeedbackReportExportQuery(FeedbackReportParameters Parameters) : IRequest; internal sealed class FeedbackReportExportHandler ( IFeedbackReportService reportService, IValidatorService validatorService -) : IRequestHandler> +) : IRequestHandler { private readonly IFeedbackReportService _reportService = reportService; private readonly IValidatorService _validator = validatorService; - public async Task> Handle(FeedbackReportExportQuery request, CancellationToken cancellationToken) + public async Task Handle(FeedbackReportExportQuery request, CancellationToken cancellationToken) { await _validator .Auth(c => c.RequirePermissions(PermissionKey.Reports_View)) @@ -27,24 +27,38 @@ await _validator var results = await _reportService.GetBaseQuery(request.Parameters, cancellationToken); - return results.Select(r => new FeedbackReportExportRecord + // we have to use the dynamic type here to accommodate the questions/answers + var records = new List(); + foreach (var r in results) { - Id = r.Id, - ChallengeSpecId = r.ChallengeSpec?.Id, - ChallengeSpecName = r.ChallengeSpec?.Name, - GameId = r.LogicalGame.Id, - GameName = r.LogicalGame.Name, - GameSeason = r.LogicalGame.Season, - GameSeries = r.LogicalGame.Series, - GameTrack = r.LogicalGame.Track, - IsTeamGame = r.LogicalGame.IsTeamGame, - SponsorId = r.Sponsor.Id, - SponsorName = r.Sponsor.Name, - UserId = r.User.Id, - UserName = r.User.Name, - WhenCreated = r.WhenCreated, - WhenEdited = r.WhenEdited, - WhenFinalized = r.WhenFinalized - }).ToArray(); + dynamic record = new ExpandoObject(); + + record.Id = r.Id; + record.ChallengeSpecId = r.ChallengeSpec?.Id; + record.ChallengeSpecName = r.ChallengeSpec?.Name; + record.GameId = r.LogicalGame.Id; + record.GameName = r.LogicalGame.Name; + record.GameSeason = r.LogicalGame.Season; + record.GameSeries = r.LogicalGame.Series; + record.GameTrack = r.LogicalGame.Track; + record.IsTeamGame = r.LogicalGame.IsTeamGame; + record.SponsorId = r.Sponsor.Id; + record.SponsorName = r.Sponsor.Name; + record.UserId = r.User.Id; + record.UserName = r.User.Name; + record.WhenCreated = r.WhenCreated; + record.WhenEdited = r.WhenEdited; + record.WhenFinalized = r.WhenFinalized; + + var asDict = record as IDictionary; + foreach (var question in r.Responses) + { + asDict.Add(question.Prompt, question.Answer); + } + + records.Add(asDict); + } + + return new FeedbackReportExportContainer { Records = records }; } } diff --git a/src/Gameboard.Api/Features/Reports/Queries/FeedbackReport/FeedbackReportModels.cs b/src/Gameboard.Api/Features/Reports/Queries/FeedbackReport/FeedbackReportModels.cs index 81ddc8ca..3707f9cf 100644 --- a/src/Gameboard.Api/Features/Reports/Queries/FeedbackReport/FeedbackReportModels.cs +++ b/src/Gameboard.Api/Features/Reports/Queries/FeedbackReport/FeedbackReportModels.cs @@ -43,6 +43,11 @@ public sealed class FeedbackReportRecord public required DateTimeOffset? WhenFinalized { get; set; } } +public sealed class FeedbackReportExportContainer +{ + public required IEnumerable Records { get; set; } +} + public sealed class FeedbackReportExportRecord { public required string Id { get; set; } diff --git a/src/Gameboard.Api/Features/Reports/ReportsExportController.cs b/src/Gameboard.Api/Features/Reports/ReportsExportController.cs index 1753b182..90dcaad0 100644 --- a/src/Gameboard.Api/Features/Reports/ReportsExportController.cs +++ b/src/Gameboard.Api/Features/Reports/ReportsExportController.cs @@ -7,6 +7,7 @@ using MediatR; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using ServiceStack.Text; namespace Gameboard.Api.Features.Reports; @@ -38,7 +39,7 @@ public async Task GetEnrollmentReportExport([FromQuery] Enrollmen public async Task GetFeedbackReportExport([FromQuery] FeedbackReportParameters parameters) { var results = await _mediator.Send(new FeedbackReportExportQuery(parameters)); - return new FileContentResult(GetReportExport(results), MimeTypes.TextCsv); + return new FileContentResult(GetReportExport(results.Records), MimeTypes.TextCsv); } [HttpGet("players")] @@ -67,7 +68,7 @@ public async Task GetSupportReport([FromQuery] SupportReportParam private byte[] GetReportExport(IEnumerable records) { - var csvText = ServiceStack.StringExtensions.ToCsv(records); + var csvText = CsvSerializer.SerializeToCsv(records); return Encoding.UTF8.GetBytes(csvText.ToString()); } } diff --git a/src/Gameboard.Api/Gameboard.Api.csproj b/src/Gameboard.Api/Gameboard.Api.csproj index 08fae89c..6a3458df 100644 --- a/src/Gameboard.Api/Gameboard.Api.csproj +++ b/src/Gameboard.Api/Gameboard.Api.csproj @@ -17,7 +17,7 @@ - +