diff --git a/api/src/Feature.Form.Submissions/GetById/Endpoint.cs b/api/src/Feature.Form.Submissions/GetById/Endpoint.cs index 706a0edac..360480ce3 100644 --- a/api/src/Feature.Form.Submissions/GetById/Endpoint.cs +++ b/api/src/Feature.Form.Submissions/GetById/Endpoint.cs @@ -45,6 +45,9 @@ WITH submissions AS (SELECT "Languages" FROM "PollingStationInformationForms" WHERE "ElectionRoundId" = @electionRoundId) AS "Languages", + (SELECT "Id" + FROM "PollingStationInformationForms" + WHERE "ElectionRoundId" = @electionRoundId) AS "FormId", psi."FollowUpStatus" as "FollowUpStatus", '[]'::jsonb AS "Attachments", '[]'::jsonb AS "Notes", @@ -67,6 +70,7 @@ UNION ALL f."Questions", f."DefaultLanguage", f."Languages", + f."Id" AS "FormId", fs."FollowUpStatus", COALESCE((select jsonb_agg(jsonb_build_object('QuestionId', "QuestionId", 'FileName', "FileName", 'MimeType', "MimeType", 'FilePath', "FilePath", 'UploadedFileName', "UploadedFileName", 'TimeSubmitted', "LastUpdatedAt")) FROM "Attachments" a @@ -95,6 +99,7 @@ UNION ALL INNER JOIN "Forms" f ON f."Id" = fs."FormId" WHERE fs."Id" = @submissionId and fs."ElectionRoundId" = @electionRoundId) SELECT s."SubmissionId", + s."FormId", s."TimeSubmitted", s."FormCode", s."FormType", diff --git a/api/src/Feature.Form.Submissions/GetByIdV2/Endpoint.cs b/api/src/Feature.Form.Submissions/GetByIdV2/Endpoint.cs new file mode 100644 index 000000000..bd579cead --- /dev/null +++ b/api/src/Feature.Form.Submissions/GetByIdV2/Endpoint.cs @@ -0,0 +1,154 @@ +using Module.Answers.Models; +using Vote.Monitor.Core.Services.FileStorage.Contracts; + +namespace Feature.Form.Submissions.GetByIdV2; + +public class Endpoint( + IAuthorizationService authorizationService, + INpgsqlConnectionFactory dbConnectionFactory, + IFileStorageService fileStorageService) : Endpoint, NotFound>> +{ + public override void Configure() + { + Get("/api/election-rounds/{electionRoundId}/form-submissions/{submissionId}:v2"); + DontAutoTag(); + Options(x => x.WithTags("form-submissions")); + Summary(s => { s.Summary = "Gets submission by id"; }); + + Policies(PolicyNames.NgoAdminsOnly); + } + + public override async Task, NotFound>> ExecuteAsync(Request req, + CancellationToken ct) + { + var authorizationResult = + await authorizationService.AuthorizeAsync(User, new MonitoringNgoAdminRequirement(req.ElectionRoundId)); + if (!authorizationResult.Succeeded) + { + return TypedResults.NotFound(); + } + + var sql = """ + WITH submissions AS + (SELECT psi."Id" AS "SubmissionId", + 'PSI' AS "FormType", + 'PSI' AS "FormCode", + psi."PollingStationId", + psi."MonitoringObserverId", + psi."Answers", + (SELECT "Id" + FROM "PollingStationInformationForms" + WHERE "ElectionRoundId" = @electionRoundId) AS "FormId", + psi."FollowUpStatus" as "FollowUpStatus", + '[]'::jsonb AS "Attachments", + '[]'::jsonb AS "Notes", + "LastUpdatedAt" AS "TimeSubmitted", + psi."ArrivalTime", + psi."DepartureTime", + psi."Breaks", + psi."IsCompleted" + FROM "PollingStationInformation" psi + INNER JOIN "GetAvailableMonitoringObservers"(@electionRoundId, @ngoId, 'Coalition') AMO on AMO."MonitoringObserverId" = psi."MonitoringObserverId" + WHERE psi."Id" = @submissionId and psi."ElectionRoundId" = @electionRoundId + UNION ALL + SELECT + fs."Id" AS "SubmissionId", + f."FormType" AS "FormType", + f."Code" AS "FormCode", + fs."PollingStationId", + fs."MonitoringObserverId", + fs."Answers", + f."Id" AS "FormId", + fs."FollowUpStatus", + COALESCE((select jsonb_agg(jsonb_build_object('QuestionId', "QuestionId", 'FileName', "FileName", 'MimeType', "MimeType", 'FilePath', "FilePath", 'UploadedFileName', "UploadedFileName", 'TimeSubmitted', "LastUpdatedAt")) + FROM "Attachments" a + WHERE + a."ElectionRoundId" = @electionRoundId + AND a."FormId" = fs."FormId" + AND a."MonitoringObserverId" = fs."MonitoringObserverId" + AND a."IsDeleted" = false AND a."IsCompleted" = true + AND fs."PollingStationId" = a."PollingStationId"),'[]'::JSONB) AS "Attachments", + + COALESCE((select jsonb_agg(jsonb_build_object('QuestionId', "QuestionId", 'Text', "Text", 'TimeSubmitted', "LastUpdatedAt")) + FROM "Notes" n + WHERE + n."ElectionRoundId" = @electionRoundId + AND n."FormId" = fs."FormId" + AND n."MonitoringObserverId" = fs."MonitoringObserverId" + AND fs."PollingStationId" = n."PollingStationId"), '[]'::JSONB) AS "Notes", + + "LastUpdatedAt" AS "TimeSubmitted", + NULL AS "ArrivalTime", + NULL AS "DepartureTime", + '[]'::jsonb AS "Breaks", + fs."IsCompleted" + FROM "FormSubmissions" fs + INNER JOIN "GetAvailableMonitoringObservers"(@electionRoundId, @ngoId, 'Coalition') AMO on AMO."MonitoringObserverId" = FS."MonitoringObserverId" + INNER JOIN "Forms" f ON f."Id" = fs."FormId" + WHERE fs."Id" = @submissionId and fs."ElectionRoundId" = @electionRoundId) + SELECT s."SubmissionId", + s."FormId", + s."TimeSubmitted", + s."FormCode", + s."FormType", + ps."Id" AS "PollingStationId", + ps."Level1", + ps."Level2", + ps."Level3", + ps."Level4", + ps."Level5", + ps."Number", + s."MonitoringObserverId", + AMO."DisplayName" "ObserverName", + AMO."Email", + AMO."PhoneNumber", + AMO."Tags", + AMO."NgoName", + AMO."IsOwnObserver", + s."Attachments", + s."Notes", + s."Answers", + s."FollowUpStatus", + s."ArrivalTime", + s."DepartureTime", + s."Breaks", + s."IsCompleted" + FROM submissions s + INNER JOIN "PollingStations" ps ON ps."Id" = s."PollingStationId" + INNER JOIN "GetAvailableMonitoringObservers"(@electionRoundId, @ngoId, 'Coalition') AMO on AMO."MonitoringObserverId" = s."MonitoringObserverId" + """; + + var queryArgs = new + { + electionRoundId = req.ElectionRoundId, ngoId = req.NgoId, submissionId = req.SubmissionId + }; + + FormSubmissionView submission = null; + + using (var dbConnection = await dbConnectionFactory.GetOpenConnectionAsync(ct)) + { + submission = await dbConnection.QueryFirstOrDefaultAsync(sql, queryArgs); + } + + if (submission is null) + { + return TypedResults.NotFound(); + } + + submission = submission with + { + Attachments = await Task.WhenAll( + submission.Attachments.Select(async attachment => + { + var result = + await fileStorageService.GetPresignedUrlAsync(attachment.FilePath, attachment.UploadedFileName); + return result is GetPresignedUrlResult.Ok(var url, _, var urlValidityInSeconds) + ? attachment with { PresignedUrl = url, UrlValidityInSeconds = urlValidityInSeconds } + : attachment; + }) + ) + }; + + return TypedResults.Ok(submission); + } +} diff --git a/api/src/Feature.Form.Submissions/GetByIdV2/Request.cs b/api/src/Feature.Form.Submissions/GetByIdV2/Request.cs new file mode 100644 index 000000000..32978d16e --- /dev/null +++ b/api/src/Feature.Form.Submissions/GetByIdV2/Request.cs @@ -0,0 +1,12 @@ +using Vote.Monitor.Core.Security; + +namespace Feature.Form.Submissions.GetByIdV2; + +public class Request +{ + public Guid ElectionRoundId { get; set; } + + [FromClaim(ApplicationClaimTypes.NgoId)] + public Guid NgoId { get; set; } + public Guid SubmissionId { get; set; } +} diff --git a/api/src/Feature.Form.Submissions/GetByIdV2/Validator.cs b/api/src/Feature.Form.Submissions/GetByIdV2/Validator.cs new file mode 100644 index 000000000..55fd88743 --- /dev/null +++ b/api/src/Feature.Form.Submissions/GetByIdV2/Validator.cs @@ -0,0 +1,11 @@ +namespace Feature.Form.Submissions.GetByIdV2; + +public class Validator : Validator +{ + public Validator() + { + RuleFor(x => x.ElectionRoundId).NotEmpty(); + RuleFor(x => x.NgoId).NotEmpty(); + RuleFor(x => x.SubmissionId).NotEmpty(); + } +} diff --git a/api/src/Feature.Form.Submissions/ListEntries/Endpoint.cs b/api/src/Feature.Form.Submissions/ListEntries/Endpoint.cs index 4d3a9bb0e..951267d32 100644 --- a/api/src/Feature.Form.Submissions/ListEntries/Endpoint.cs +++ b/api/src/Feature.Form.Submissions/ListEntries/Endpoint.cs @@ -125,6 +125,7 @@ OR mo."PhoneNumber" ILIKE @searchText WITH polling_station_submissions AS (SELECT psi."Id" AS "SubmissionId", 'PSI' AS "FormType", 'PSI' AS "FormCode", + psif."Id" "FormId", psi."PollingStationId", psi."MonitoringObserverId", psi."NumberOfQuestionsAnswered", @@ -166,6 +167,7 @@ OR mo."PhoneNumber" ILIKE @searchText form_submissions AS (SELECT fs."Id" AS "SubmissionId", f."FormType", f."Code" AS "FormCode", + f."Id" AS "FormId", fs."PollingStationId", fs."MonitoringObserverId", fs."NumberOfQuestionsAnswered", @@ -212,6 +214,7 @@ OR mo."PhoneNumber" ILIKE @searchText OR (@questionsAnswered = 'None' AND fs."NumberOfQuestionsAnswered" = 0))) SELECT s."SubmissionId", s."TimeSubmitted", + s."FormId", s."FormCode", s."FormType", s."DefaultLanguage", diff --git a/api/src/Feature.Form.Submissions/ListEntries/FormSubmissionEntry.cs b/api/src/Feature.Form.Submissions/ListEntries/FormSubmissionEntry.cs index 1ac287587..60a2abcb1 100644 --- a/api/src/Feature.Form.Submissions/ListEntries/FormSubmissionEntry.cs +++ b/api/src/Feature.Form.Submissions/ListEntries/FormSubmissionEntry.cs @@ -11,6 +11,7 @@ public record FormSubmissionEntry public Guid SubmissionId { get; init; } public DateTime TimeSubmitted { get; init; } + public Guid FormId { get; init; } public string FormCode { get; init; } = null!; public TranslatedString FormName { get; init; } = null!; diff --git a/api/src/Feature.Forms/Get/Endpoint.cs b/api/src/Feature.Forms/Get/Endpoint.cs index b668acbbc..453d07fa4 100644 --- a/api/src/Feature.Forms/Get/Endpoint.cs +++ b/api/src/Feature.Forms/Get/Endpoint.cs @@ -3,6 +3,7 @@ using Feature.Forms.Specifications; using Microsoft.AspNetCore.Authorization; using Vote.Monitor.Domain.Entities.CoalitionAggregate; +using Vote.Monitor.Domain.Entities.PollingStationInfoFormAggregate; using GetCoalitionFormSpecification = Feature.Forms.Specifications.GetCoalitionFormSpecification; namespace Feature.Forms.Get; @@ -10,7 +11,8 @@ namespace Feature.Forms.Get; public class Endpoint( IAuthorizationService authorizationService, IReadRepository formRepository, - IReadRepository coalitionRepository) : Endpoint, NotFound>> + IReadRepository coalitionRepository, + IReadRepository psiFormRepository) : Endpoint, NotFound>> { public override void Configure() { @@ -27,12 +29,20 @@ public override async Task, NotFound>> ExecuteAsync(Re { return TypedResults.NotFound(); } - + var psiFormSpecification = + new GetPsiFormById(req.ElectionRoundId, req.Id); var coalitionFormSpecification = new GetCoalitionFormSpecification(req.ElectionRoundId, req.NgoId, req.Id); var ngoFormSpecification = new GetFormByIdSpecification(req.ElectionRoundId, req.NgoId, req.Id); + var psiForm = await psiFormRepository.FirstOrDefaultAsync(psiFormSpecification, ct); + + if (psiForm is not null) + { + return TypedResults.Ok(FormFullModel.FromEntity(psiForm)); + } + var form = (await coalitionRepository.FirstOrDefaultAsync(coalitionFormSpecification, ct)) ?? (await formRepository.FirstOrDefaultAsync(ngoFormSpecification, ct)); diff --git a/api/src/Feature.Forms/Models/FormFullModel.cs b/api/src/Feature.Forms/Models/FormFullModel.cs index 1ecaba895..20c70856f 100644 --- a/api/src/Feature.Forms/Models/FormFullModel.cs +++ b/api/src/Feature.Forms/Models/FormFullModel.cs @@ -3,6 +3,7 @@ using Vote.Monitor.Domain.Entities.FormBase; using Module.Forms.Mappers; using Module.Forms.Models; +using Vote.Monitor.Domain.Entities.PollingStationInfoFormAggregate; namespace Feature.Forms.Models; @@ -48,4 +49,22 @@ public static FormFullModel FromEntity(FormAggregate form) => form == null Icon = form.Icon, DisplayOrder = form.DisplayOrder }; + + public static FormFullModel FromEntity(PollingStationInformationForm form) => form == null + ? null + : new FormFullModel + { + Id = form.Id, + Code = form.Code, + FormType = form.FormType, + Status = form.Status, + DefaultLanguage = form.DefaultLanguage, + Languages = form.Languages, + Name = form.Name, + Questions = form.Questions.Select(QuestionsMapper.ToModel).ToList(), + NumberOfQuestions = form.NumberOfQuestions, + Description = form.Description, + LanguagesTranslationStatus = form.LanguagesTranslationStatus, + Icon = form.Icon, + }; } diff --git a/api/src/Feature.Forms/Specifications/GetPsiFormById.cs b/api/src/Feature.Forms/Specifications/GetPsiFormById.cs new file mode 100644 index 000000000..ee910a78b --- /dev/null +++ b/api/src/Feature.Forms/Specifications/GetPsiFormById.cs @@ -0,0 +1,11 @@ +using Vote.Monitor.Domain.Entities.PollingStationInfoFormAggregate; + +namespace Feature.Forms.Specifications; + +public sealed class GetPsiFormById : SingleResultSpecification +{ + public GetPsiFormById(Guid electionRoundId, Guid id) + { + Query.Where(x => x.ElectionRoundId == electionRoundId && x.Id == id); + } +} diff --git a/api/src/Module.Answers/Models/FormSubmissionView.cs b/api/src/Module.Answers/Models/FormSubmissionView.cs index 89f30be58..3f5a7f0c7 100644 --- a/api/src/Module.Answers/Models/FormSubmissionView.cs +++ b/api/src/Module.Answers/Models/FormSubmissionView.cs @@ -9,6 +9,7 @@ public record FormSubmissionView { public Guid SubmissionId { get; init; } public DateTime TimeSubmitted { get; init; } + public Guid FormId { get; init; } public string FormCode { get; init; } public string DefaultLanguage { get; init; } public string[] Languages { get; init; } = []; diff --git a/api/tests/Feature.Forms.UnitTests/Endpoints/GetEndpointTests.cs b/api/tests/Feature.Forms.UnitTests/Endpoints/GetEndpointTests.cs index a2e7ab5b1..813184aab 100644 --- a/api/tests/Feature.Forms.UnitTests/Endpoints/GetEndpointTests.cs +++ b/api/tests/Feature.Forms.UnitTests/Endpoints/GetEndpointTests.cs @@ -4,6 +4,7 @@ using NSubstitute.ReturnsExtensions; using Vote.Monitor.Domain.Entities.CoalitionAggregate; using Vote.Monitor.Domain.Entities.FormAggregate; +using Vote.Monitor.Domain.Entities.PollingStationInfoFormAggregate; namespace Feature.Forms.UnitTests.Endpoints; @@ -12,11 +13,12 @@ public class GetEndpointTests private readonly IAuthorizationService _authorizationService = Substitute.For(); private readonly IReadRepository
_repository = Substitute.For>(); private readonly IReadRepository _coalitionRepository = Substitute.For>(); + private readonly IReadRepository _psiFormRepository = Substitute.For>(); private readonly Get.Endpoint _endpoint; public GetEndpointTests() { - _endpoint = Factory.Create(_authorizationService, _repository, _coalitionRepository); + _endpoint = Factory.Create(_authorizationService, _repository, _coalitionRepository, _psiFormRepository); _authorizationService .AuthorizeAsync(Arg.Any(), Arg.Any(), Arg.Any>()).Returns(AuthorizationResult.Success()); @@ -42,6 +44,28 @@ public async Task ShouldReturnNotFound_WhenUserIsNotAuthorized() .Which .Result.Should().BeOfType(); } + + [Fact] + public async Task Should_ReturnPSIForm_WhenFormExists() + { + // Arrange + var psiForm = new PollingStationInformationFormFaker().Generate(); + + _psiFormRepository + .FirstOrDefaultAsync(Arg.Any()) + .Returns(psiForm); + + // Act + var request = new Get.Request { Id = psiForm.Id }; + var result = await _endpoint.ExecuteAsync(request, CancellationToken.None); + + // Assert + result + .Should().BeOfType, NotFound>>() + .Which + .Result.Should().BeOfType>() + .Which.Value.Should().BeEquivalentTo(psiForm, options => options.ExcludingMissingMembers()); + } [Fact] public async Task Should_ReturnForm_WhenFormExists()