diff --git a/api/Directory.Packages.props b/api/Directory.Packages.props
index e2c5013d9..6ae8957be 100644
--- a/api/Directory.Packages.props
+++ b/api/Directory.Packages.props
@@ -1,92 +1,92 @@
-
- true
- enable
- enable
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+ true
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/api/src/Feature.Attachments/AttachmentModel.cs b/api/src/Feature.Attachments/AttachmentModel.cs
index a8bc13fad..5b2374a36 100644
--- a/api/src/Feature.Attachments/AttachmentModel.cs
+++ b/api/src/Feature.Attachments/AttachmentModel.cs
@@ -1,5 +1,7 @@
namespace Feature.Attachments;
+[Obsolete("Will be removed in future version")]
+
public record AttachmentModel
{
public required Guid Id { get; init; }
diff --git a/api/src/Feature.Attachments/AttachmentModelV2.cs b/api/src/Feature.Attachments/AttachmentModelV2.cs
new file mode 100644
index 000000000..9d4c5adbe
--- /dev/null
+++ b/api/src/Feature.Attachments/AttachmentModelV2.cs
@@ -0,0 +1,13 @@
+namespace Feature.Attachments;
+
+public record AttachmentModelV2
+{
+ public required Guid Id { get; init; }
+ public required Guid ElectionRoundId { get; init; }
+ public Guid SubmissionId { get; init; }
+ public required Guid QuestionId { get; init; }
+ public required string FileName { get; init; } = string.Empty;
+ public required string MimeType { get; init; } = string.Empty;
+ public required string PresignedUrl { get; init; } = string.Empty;
+ public required int UrlValidityInSeconds { get; init; }
+}
diff --git a/api/src/Feature.Attachments/Delete/Endpoint.cs b/api/src/Feature.Attachments/Delete/Endpoint.cs
index b9e0d74fd..00d78127f 100644
--- a/api/src/Feature.Attachments/Delete/Endpoint.cs
+++ b/api/src/Feature.Attachments/Delete/Endpoint.cs
@@ -1,39 +1,38 @@
using Authorization.Policies.Requirements;
+using Feature.Attachments.Specifications;
using Microsoft.AspNetCore.Authorization;
namespace Feature.Attachments.Delete;
-public class Endpoint : Endpoint>>
+public class Endpoint(
+ IAuthorizationService authorizationService,
+ IRepository repository)
+ : Endpoint>>
{
- private readonly IAuthorizationService _authorizationService;
- private readonly IRepository _repository;
-
- public Endpoint(IAuthorizationService authorizationService,
- IRepository repository)
- {
- _repository = repository;
- _authorizationService = authorizationService;
- }
-
public override void Configure()
{
Delete("/api/election-rounds/{electionRoundId}/attachments/{id}");
DontAutoTag();
Options(x => x.WithTags("attachments", "mobile"));
- Summary(s => {
+ Summary(s =>
+ {
s.Summary = "Deletes an attachment";
});
}
- public override async Task>> ExecuteAsync(Request req, CancellationToken ct)
+ public override async Task>> ExecuteAsync(Request req,
+ CancellationToken ct)
{
- var authorizationResult = await _authorizationService.AuthorizeAsync(User, new MonitoringObserverRequirement(req.ElectionRoundId));
+ var authorizationResult =
+ await authorizationService.AuthorizeAsync(User, new MonitoringObserverRequirement(req.ElectionRoundId));
if (!authorizationResult.Succeeded)
{
return TypedResults.NotFound();
}
- var attachment = await _repository.GetByIdAsync(req.Id, ct);
+ var specification = new GetAttachmentByIdSpecification(req.ElectionRoundId, req.ObserverId, req.Id);
+ var attachment = await repository.FirstOrDefaultAsync(specification, ct);
+
if (attachment is null)
{
return TypedResults.NotFound();
@@ -41,8 +40,8 @@ public override async Task, BadRequest repository,
+ IFileStorageService fileStorageService)
+ : Endpoint, BadRequest, NotFound>>
+{
+ public override void Configure()
+ {
+ Get("/api/election-rounds/{electionRoundId}/form-submissions/{submissionId}/attachments/{id}");
+ DontAutoTag();
+ Options(x => x.WithTags("attachments", "mobile"));
+ Summary(s => {
+ s.Summary = "Gets an attachment";
+ s.Description = "Gets an attachment with freshly generated presigned url";
+ });
+ }
+
+ public override async Task, BadRequest, NotFound>> ExecuteAsync(Request req, CancellationToken ct)
+ {
+ var authorizationResult = await authorizationService.AuthorizeAsync(User, new MonitoringObserverRequirement(req.ElectionRoundId));
+ if (!authorizationResult.Succeeded)
+ {
+ return TypedResults.NotFound();
+ }
+
+ var specification = new GetAttachmentByIdSpecification(req.ElectionRoundId, req.ObserverId, req.Id);
+ var attachment = await repository.FirstOrDefaultAsync(specification, ct);
+
+ if (attachment is null)
+ {
+ return TypedResults.NotFound();
+ }
+
+ var presignedUrl = await fileStorageService.GetPresignedUrlAsync(attachment.FilePath, attachment.UploadedFileName);
+
+ return TypedResults.Ok(new AttachmentModelV2
+ {
+ Id = attachment.Id,
+ ElectionRoundId = req.ElectionRoundId,
+ FileName = attachment.FileName,
+ PresignedUrl = (presignedUrl as GetPresignedUrlResult.Ok)?.Url ?? string.Empty,
+ MimeType = attachment.MimeType,
+ UrlValidityInSeconds = (presignedUrl as GetPresignedUrlResult.Ok)?.UrlValidityInSeconds ?? 0,
+ SubmissionId = attachment.SubmissionId,
+ QuestionId = attachment.QuestionId
+ });
+ }
+}
diff --git a/api/src/Feature.Attachments/GetV2/Request.cs b/api/src/Feature.Attachments/GetV2/Request.cs
new file mode 100644
index 000000000..7b83d4cc7
--- /dev/null
+++ b/api/src/Feature.Attachments/GetV2/Request.cs
@@ -0,0 +1,15 @@
+using Vote.Monitor.Core.Security;
+
+namespace Feature.Attachments.GetV2;
+
+public class Request
+{
+ public Guid ElectionRoundId { get; set; }
+
+ public Guid SubmissionId { get; set; }
+
+ [FromClaim(ApplicationClaimTypes.UserId)]
+ public Guid ObserverId { get; set; }
+
+ public Guid Id { get; set; }
+}
diff --git a/api/src/Feature.Attachments/GetV2/Validator.cs b/api/src/Feature.Attachments/GetV2/Validator.cs
new file mode 100644
index 000000000..6ec4966ed
--- /dev/null
+++ b/api/src/Feature.Attachments/GetV2/Validator.cs
@@ -0,0 +1,12 @@
+namespace Feature.Attachments.GetV2;
+
+public class Validator : Validator
+{
+ public Validator()
+ {
+ RuleFor(x => x.ElectionRoundId).NotEmpty();
+ RuleFor(x => x.SubmissionId).NotEmpty();
+ RuleFor(x => x.ObserverId).NotEmpty();
+ RuleFor(x => x.Id).NotEmpty();
+ }
+}
diff --git a/api/src/Feature.Attachments/InitiateUploadV2/Endpoint.cs b/api/src/Feature.Attachments/InitiateUploadV2/Endpoint.cs
new file mode 100644
index 000000000..a908cbe17
--- /dev/null
+++ b/api/src/Feature.Attachments/InitiateUploadV2/Endpoint.cs
@@ -0,0 +1,77 @@
+using Authorization.Policies.Requirements;
+using Feature.Attachments.Specifications;
+using Microsoft.AspNetCore.Authorization;
+using Vote.Monitor.Core.Services.FileStorage.Contracts;
+using Vote.Monitor.Core.Services.Time;
+using Vote.Monitor.Domain.Entities.MonitoringObserverAggregate;
+
+namespace Feature.Attachments.InitiateUploadV2;
+
+public class Endpoint(
+ IAuthorizationService authorizationService,
+ IRepository repository,
+ IFileStorageService fileStorageService,
+ IReadRepository monitoringObserverRepository,
+ ITimeProvider timeProvider)
+ : Endpoint, NotFound, Conflict>>
+{
+ public override void Configure()
+ {
+ Post("/api/election-rounds/{electionRoundId}/form-submissions/{submissionId}/attachments:init");
+ DontAutoTag();
+ Options(x => x.WithTags("attachments", "mobile"));
+ Summary(s =>
+ {
+ s.Summary =
+ "Creates an attachment for a specific polling station and gets back details for uploading it in the file storage";
+ });
+ }
+
+ public override async Task, NotFound, Conflict>> ExecuteAsync(Request req,
+ CancellationToken ct)
+ {
+ var authorizationResult =
+ await authorizationService.AuthorizeAsync(User, new MonitoringObserverRequirement(req.ElectionRoundId));
+ if (!authorizationResult.Succeeded)
+ {
+ return TypedResults.NotFound();
+ }
+
+ var specification = new GetAttachmentByIdSpecification(req.ElectionRoundId, req.ObserverId, req.Id);
+ var duplicatedAttachmentExist = await repository.AnyAsync(specification, ct);
+ if (duplicatedAttachmentExist)
+ {
+ return TypedResults.Conflict();
+ }
+
+ var monitoringObserverSpecification =
+ new GetMonitoringObserverIdSpecification(req.ElectionRoundId, req.ObserverId);
+ var monitoringObserverId =
+ await monitoringObserverRepository.FirstOrDefaultAsync(monitoringObserverSpecification, ct);
+
+ var uploadPath =
+ $"elections/{req.ElectionRoundId}/submissions/{req.SubmissionId}";
+
+ var attachment = AttachmentAggregate.CreateV2(req.Id,
+ req.SubmissionId,
+ monitoringObserverId,
+ req.QuestionId,
+ req.FileName,
+ uploadPath,
+ req.ContentType,
+ req.LastUpdatedAt);
+
+ var uploadResult = await fileStorageService.CreateMultipartUploadAsync(uploadPath,
+ fileName: attachment.UploadedFileName,
+ contentType: req.ContentType,
+ numberOfUploadParts: req.NumberOfUploadParts,
+ ct: ct);
+
+ await repository.AddAsync(attachment, ct);
+
+ return TypedResults.Ok(new Response
+ {
+ UploadId = uploadResult.UploadId, UploadUrls = uploadResult.PresignedUrls
+ });
+ }
+}
diff --git a/api/src/Feature.Attachments/InitiateUploadV2/Request.cs b/api/src/Feature.Attachments/InitiateUploadV2/Request.cs
new file mode 100644
index 000000000..6044c4f10
--- /dev/null
+++ b/api/src/Feature.Attachments/InitiateUploadV2/Request.cs
@@ -0,0 +1,20 @@
+using Vote.Monitor.Core.Security;
+
+namespace Feature.Attachments.InitiateUploadV2;
+
+public class Request
+{
+ public Guid ElectionRoundId { get; set; }
+
+ public Guid SubmissionId { get; set; }
+
+ [FromClaim(ApplicationClaimTypes.UserId)]
+ public Guid ObserverId { get; set; }
+
+ public Guid Id { get; set; }
+ public Guid QuestionId { get; set; }
+ public string FileName { get; set; }
+ public string ContentType { get; set; }
+ public int NumberOfUploadParts { get; set; }
+ public DateTime LastUpdatedAt { get; set; }
+}
diff --git a/api/src/Feature.Attachments/InitiateUploadV2/Response.cs b/api/src/Feature.Attachments/InitiateUploadV2/Response.cs
new file mode 100644
index 000000000..ef523058d
--- /dev/null
+++ b/api/src/Feature.Attachments/InitiateUploadV2/Response.cs
@@ -0,0 +1,7 @@
+namespace Feature.Attachments.InitiateUploadV2;
+
+public class Response
+{
+ public string UploadId { get; set; }
+ public Dictionary UploadUrls { get; set; }
+}
diff --git a/api/src/Feature.Attachments/InitiateUploadV2/Validator.cs b/api/src/Feature.Attachments/InitiateUploadV2/Validator.cs
new file mode 100644
index 000000000..484a95453
--- /dev/null
+++ b/api/src/Feature.Attachments/InitiateUploadV2/Validator.cs
@@ -0,0 +1,25 @@
+namespace Feature.Attachments.InitiateUploadV2;
+
+public class Validator : Validator
+{
+ public Validator()
+ {
+ RuleFor(x => x.ElectionRoundId).NotEmpty();
+ RuleFor(x => x.SubmissionId).NotEmpty();
+ RuleFor(x => x.ObserverId).NotEmpty();
+ RuleFor(x => x.Id).NotEmpty();
+ RuleFor(x => x.QuestionId).NotEmpty();
+ RuleFor(x => x.NumberOfUploadParts).GreaterThan(0);
+ RuleFor(x => x.FileName).NotEmpty();
+ RuleFor(x => x.ContentType).NotEmpty();
+
+ RuleFor(x => x.LastUpdatedAt)
+ .Must(BeUtc)
+ .WithMessage("LastUpdatedAt must be in UTC format.");
+ }
+
+ private bool BeUtc(DateTime date)
+ {
+ return date.Kind == DateTimeKind.Utc;
+ }
+}
diff --git a/api/src/Feature.Attachments/List/Endpoint.cs b/api/src/Feature.Attachments/List/Endpoint.cs
index b8215144d..2abb6a2db 100644
--- a/api/src/Feature.Attachments/List/Endpoint.cs
+++ b/api/src/Feature.Attachments/List/Endpoint.cs
@@ -5,6 +5,7 @@
namespace Feature.Attachments.List;
+[Obsolete("Will be removed in future version")]
public class Endpoint : Endpoint>, NotFound, BadRequest>>
{
private readonly IAuthorizationService _authorizationService;
diff --git a/api/src/Feature.Attachments/ListV2/Endpoint.cs b/api/src/Feature.Attachments/ListV2/Endpoint.cs
new file mode 100644
index 000000000..88c446e30
--- /dev/null
+++ b/api/src/Feature.Attachments/ListV2/Endpoint.cs
@@ -0,0 +1,70 @@
+using Authorization.Policies.Requirements;
+using Feature.Attachments.Specifications;
+using Microsoft.AspNetCore.Authorization;
+using Vote.Monitor.Core.Services.FileStorage.Contracts;
+
+namespace Feature.Attachments.ListV2;
+
+public class Endpoint : Endpoint>, NotFound, BadRequest>>
+{
+ private readonly IAuthorizationService _authorizationService;
+ private readonly IReadRepository _repository;
+ private readonly IFileStorageService _fileStorageService;
+
+ public Endpoint(IAuthorizationService authorizationService,
+ IReadRepository repository,
+ IFileStorageService fileStorageService)
+ {
+ _repository = repository;
+ _fileStorageService = fileStorageService;
+ _authorizationService = authorizationService;
+ }
+
+ public override void Configure()
+ {
+ Get("/api/election-rounds/{electionRoundId}/form-submissions/{submissionId}/attachments");
+ DontAutoTag();
+ Options(x => x.WithTags("attachments", "mobile"));
+ Summary(s =>
+ {
+ s.Summary = "Gets all attachments an observer has uploaded for a submission";
+ s.Description = "Gets all attachments with freshly generated presigned urls";
+ });
+ }
+
+ public override async Task>, NotFound, BadRequest>> ExecuteAsync(Request req, CancellationToken ct)
+ {
+ var authorizationResult = await _authorizationService.AuthorizeAsync(User, new MonitoringObserverRequirement(req.ElectionRoundId));
+ if (!authorizationResult.Succeeded)
+ {
+ return TypedResults.NotFound();
+ }
+
+ var specification = new GetObserverAttachmentsSpecification(req.ElectionRoundId, req.SubmissionId, req.ObserverId);
+ var attachments = await _repository.ListAsync(specification, ct);
+
+ var tasks = attachments
+ .Select(async attachment =>
+ {
+ var presignedUrl = await _fileStorageService.GetPresignedUrlAsync(
+ attachment.FilePath,
+ attachment.UploadedFileName);
+
+ return new AttachmentModelV2
+ {
+ Id = attachment.Id,
+ ElectionRoundId = req.ElectionRoundId,
+ FileName = attachment.FileName,
+ PresignedUrl = (presignedUrl as GetPresignedUrlResult.Ok)?.Url ?? string.Empty,
+ MimeType = attachment.MimeType,
+ UrlValidityInSeconds = (presignedUrl as GetPresignedUrlResult.Ok)?.UrlValidityInSeconds ?? 0,
+ SubmissionId = attachment.SubmissionId,
+ QuestionId = attachment.QuestionId
+ };
+ });
+
+ var result = await Task.WhenAll(tasks);
+
+ return TypedResults.Ok(result.ToList());
+ }
+}
diff --git a/api/src/Feature.Attachments/ListV2/Request.cs b/api/src/Feature.Attachments/ListV2/Request.cs
new file mode 100644
index 000000000..b370588a9
--- /dev/null
+++ b/api/src/Feature.Attachments/ListV2/Request.cs
@@ -0,0 +1,14 @@
+using Vote.Monitor.Core.Security;
+
+namespace Feature.Attachments.ListV2;
+
+public class Request
+{
+ public Guid ElectionRoundId { get; set; }
+
+ public Guid SubmissionId { get; set; }
+
+ [FromClaim(ApplicationClaimTypes.UserId)]
+ public Guid ObserverId { get; set; }
+
+}
diff --git a/api/src/Feature.Attachments/ListV2/Validator.cs b/api/src/Feature.Attachments/ListV2/Validator.cs
new file mode 100644
index 000000000..5e3a72a06
--- /dev/null
+++ b/api/src/Feature.Attachments/ListV2/Validator.cs
@@ -0,0 +1,11 @@
+namespace Feature.Attachments.ListV2;
+
+public class Validator : Validator
+{
+ public Validator()
+ {
+ RuleFor(x => x.ElectionRoundId).NotEmpty();
+ RuleFor(x => x.SubmissionId).NotEmpty();
+ RuleFor(x => x.ObserverId).NotEmpty();
+ }
+}
diff --git a/api/src/Feature.Attachments/Specifications/GetAttachmentByIdSpecification.cs b/api/src/Feature.Attachments/Specifications/GetAttachmentByIdSpecification.cs
index 1236291a1..e55cdbe2a 100644
--- a/api/src/Feature.Attachments/Specifications/GetAttachmentByIdSpecification.cs
+++ b/api/src/Feature.Attachments/Specifications/GetAttachmentByIdSpecification.cs
@@ -6,8 +6,7 @@ public sealed class GetAttachmentByIdSpecification : SingleResultSpecification x.ElectionRoundId == electionRoundId
- && x.MonitoringObserver.ElectionRoundId == electionRoundId
+ Query.Where(x => x.MonitoringObserver.ElectionRoundId == electionRoundId
&& x.MonitoringObserver.ObserverId == observerId
&& x.Id == attachmentId);
}
diff --git a/api/src/Feature.Attachments/Specifications/GetObserverAttachmentsSpecification.cs b/api/src/Feature.Attachments/Specifications/GetObserverAttachmentsSpecification.cs
index b75d4f027..cd0c7ec43 100644
--- a/api/src/Feature.Attachments/Specifications/GetObserverAttachmentsSpecification.cs
+++ b/api/src/Feature.Attachments/Specifications/GetObserverAttachmentsSpecification.cs
@@ -4,6 +4,8 @@ namespace Feature.Attachments.Specifications;
public sealed class GetObserverAttachmentsSpecification : Specification
{
+ [Obsolete("Will be removed in future version")]
+
public GetObserverAttachmentsSpecification(Guid electionRoundId, Guid pollingStationId, Guid observerId, Guid formId)
{
Query
@@ -11,9 +13,19 @@ public GetObserverAttachmentsSpecification(Guid electionRoundId, Guid pollingSta
&& x.PollingStationId == pollingStationId
&& x.MonitoringObserver.ObserverId == observerId
&& x.MonitoringObserver.ElectionRoundId == electionRoundId
- && x.Form.ElectionRoundId == electionRoundId
&& x.FormId == formId
&& x.IsDeleted == false
&& x.IsCompleted == true);
+ }
+
+ public GetObserverAttachmentsSpecification(Guid electionRoundId, Guid submissionId, Guid observerId)
+ {
+ Query
+ .Where(x =>
+ x.SubmissionId == submissionId
+ && x.MonitoringObserver.ObserverId == observerId
+ && x.MonitoringObserver.ElectionRoundId == electionRoundId
+ && x.IsDeleted == false
+ && x.IsCompleted == true);
}
}
diff --git a/api/src/Feature.ElectionRounds/Create/Endpoint.cs b/api/src/Feature.ElectionRounds/Create/Endpoint.cs
index f7e72842a..83b495afd 100644
--- a/api/src/Feature.ElectionRounds/Create/Endpoint.cs
+++ b/api/src/Feature.ElectionRounds/Create/Endpoint.cs
@@ -1,5 +1,4 @@
-using Feature.ElectionRounds.Specifications;
-using Vote.Monitor.Core.Constants;
+using Vote.Monitor.Core.Constants;
using Vote.Monitor.Core.Models;
using Vote.Monitor.Domain.Entities.FormBase.Questions;
using Vote.Monitor.Domain.Entities.PollingStationInfoFormAggregate;
@@ -63,7 +62,8 @@ public override async Task, Conflict, NotFound>> GetElectionRoundAs
CoalitionName = null,
IsCoalitionLeader = null,
IsMonitoringNgoForCitizenReporting = null,
+ AllowMultipleFormSubmission = null,
})
.AsSplitQuery()
.FirstOrDefaultAsync(ct);
@@ -107,7 +108,8 @@ private async Task, NotFound>> GetElectionRoundAs
.Where(c =>
c.Memberships.Any(m => m.MonitoringNgoId == x.Id) && c.ElectionRoundId == x.ElectionRoundId)
.Select(c => c.Id)
- .FirstOrDefault()
+ .FirstOrDefault(),
+ AllowMultipleFormSubmission = x.AllowMultipleFormSubmission
})
.FirstOrDefaultAsync(ct);
diff --git a/api/src/Feature.ElectionRounds/Monitoring/Endpoint.cs b/api/src/Feature.ElectionRounds/Monitoring/Endpoint.cs
index 111d251ac..f9e6df736 100644
--- a/api/src/Feature.ElectionRounds/Monitoring/Endpoint.cs
+++ b/api/src/Feature.ElectionRounds/Monitoring/Endpoint.cs
@@ -57,7 +57,8 @@ public override async Task> ExecuteAsync(Request req, CancellationTok
.Where(c =>
c.Memberships.Any(m => m.MonitoringNgoId == x.Id) && c.ElectionRoundId == x.ElectionRoundId)
.Select(c => c.Id)
- .FirstOrDefault()
+ .FirstOrDefault(),
+ AllowMultipleFormSubmission = x.AllowMultipleFormSubmission
}).ToListAsync(ct);
return TypedResults.Ok(new Result { ElectionRounds = electionRounds });
diff --git a/api/src/Feature.ElectionRounds/Specifications/GetObserverElectionSpecification.cs b/api/src/Feature.ElectionRounds/Specifications/GetObserverElectionSpecification.cs
index b7dbda155..068907ebd 100644
--- a/api/src/Feature.ElectionRounds/Specifications/GetObserverElectionSpecification.cs
+++ b/api/src/Feature.ElectionRounds/Specifications/GetObserverElectionSpecification.cs
@@ -31,7 +31,8 @@ public GetObserverElectionSpecification(Guid observerId)
CoalitionId = null,
CoalitionName = null,
IsCoalitionLeader = false,
- IsMonitoringNgoForCitizenReporting = false
+ IsMonitoringNgoForCitizenReporting = false,
+ AllowMultipleFormSubmission = x.MonitoringNgos.First(ngo => ngo.MonitoringObservers.Any(o => o.ObserverId == observerId)).AllowMultipleFormSubmission
});
}
}
diff --git a/api/src/Feature.ElectionRounds/Specifications/GetObserverElectionV2Specification.cs b/api/src/Feature.ElectionRounds/Specifications/GetObserverElectionV2Specification.cs
index 4d571d429..53fb50f87 100644
--- a/api/src/Feature.ElectionRounds/Specifications/GetObserverElectionV2Specification.cs
+++ b/api/src/Feature.ElectionRounds/Specifications/GetObserverElectionV2Specification.cs
@@ -32,7 +32,8 @@ public GetObserverElectionV2Specification(Guid observerId)
CoalitionId = null,
CoalitionName = null,
IsCoalitionLeader = false,
- IsMonitoringNgoForCitizenReporting = false
+ IsMonitoringNgoForCitizenReporting = false,
+ AllowMultipleFormSubmission = x.MonitoringNgos.First(ngo => ngo.MonitoringObservers.Any(o => o.ObserverId == observerId)).AllowMultipleFormSubmission
});
}
}
diff --git a/api/src/Feature.ElectionRounds/Specifications/ListElectionRoundsSpecification.cs b/api/src/Feature.ElectionRounds/Specifications/ListElectionRoundsSpecification.cs
deleted file mode 100644
index e2e733d22..000000000
--- a/api/src/Feature.ElectionRounds/Specifications/ListElectionRoundsSpecification.cs
+++ /dev/null
@@ -1,39 +0,0 @@
-using Vote.Monitor.Domain.Specifications;
-
-namespace Feature.ElectionRounds.Specifications;
-
-public sealed class ListElectionRoundsSpecification : Specification
-{
- public ListElectionRoundsSpecification(List.Request request)
- {
- Query
- .Include(x=>x.Country)
- .Search(x => x.Title, "%" + request.SearchText + "%", !string.IsNullOrEmpty(request.SearchText))
- .Where(x => x.Status == request.ElectionRoundStatus, request.ElectionRoundStatus != null)
- .Where(x => x.CountryId == request.CountryId, request.CountryId != null)
- .ApplyOrdering(request)
- .Paginate(request);
-
- Query.Select(x => new ElectionRoundModel
- {
- Id = x.Id,
- Title = x.Title,
- EnglishTitle = x.EnglishTitle,
- StartDate = x.StartDate,
- Status = x.Status,
- CreatedOn = x.CreatedOn,
- LastModifiedOn = x.LastModifiedOn,
- CountryId = x.CountryId,
- CountryIso2 = x.Country.Iso2,
- CountryIso3 = x.Country.Iso3,
- CountryName = x.Country.Name,
- CountryFullName = x.Country.FullName,
- CountryNumericCode = x.Country.NumericCode,
- CoalitionId = null,
- CoalitionName = null,
- IsCoalitionLeader = null,
- IsMonitoringNgoForCitizenReporting = null,
- NumberOfNgosMonitoring = x.MonitoringNgos.Count
- });
- }
-}
diff --git a/api/src/Feature.Form.Submissions/Delete/Endpoint.cs b/api/src/Feature.Form.Submissions/Delete/Endpoint.cs
index b61c0b2ba..389535293 100644
--- a/api/src/Feature.Form.Submissions/Delete/Endpoint.cs
+++ b/api/src/Feature.Form.Submissions/Delete/Endpoint.cs
@@ -8,7 +8,7 @@ public class Endpoint(IAuthorizationService authorizationService, VoteMonitorCon
{
public override void Configure()
{
- Delete("/api/election-rounds/{electionRoundId}/form-submissions");
+ Delete("/api/election-rounds/{electionRoundId}/form-submissions/{submissionId}");
DontAutoTag();
Options(x => x.WithTags("form-submissions", "mobile"));
Summary(s => { s.Summary = "Deletes a form submission for a polling station"; });
@@ -22,34 +22,27 @@ public override async Task> ExecuteAsync(Request re
await authorizationService.AuthorizeAsync(User, new MonitoringObserverRequirement(req.ElectionRoundId));
if (!authorizationResult.Succeeded)
{
- return TypedResults.NotFound();
+ return TypedResults.NotFound();
}
await context.FormSubmissions
.Where(x => x.ElectionRoundId == req.ElectionRoundId
- && x.FormId == req.FormId
+ && x.Id == req.SubmissionId
&& x.Form.ElectionRoundId == req.ElectionRoundId
&& x.MonitoringObserver.ObserverId == req.ObserverId
- && x.MonitoringObserver.ElectionRoundId == req.ElectionRoundId
- && x.PollingStationId == req.PollingStationId)
+ && x.MonitoringObserver.ElectionRoundId == req.ElectionRoundId)
.ExecuteDeleteAsync(ct);
await context.Notes
- .Where(x => x.ElectionRoundId == req.ElectionRoundId
- && x.FormId == req.FormId
- && x.Form.ElectionRoundId == req.ElectionRoundId
+ .Where(x => x.SubmissionId == req.SubmissionId
&& x.MonitoringObserver.ObserverId == req.ObserverId
- && x.MonitoringObserver.ElectionRoundId == req.ElectionRoundId
- && x.PollingStationId == req.PollingStationId)
+ && x.MonitoringObserver.ElectionRoundId == req.ElectionRoundId)
.ExecuteDeleteAsync(ct);
await context.Attachments
- .Where(x => x.ElectionRoundId == req.ElectionRoundId
- && x.FormId == req.FormId
- && x.Form.ElectionRoundId == req.ElectionRoundId
- && x.MonitoringObserver.ObserverId == req.ObserverId
+ .Where(x => x.MonitoringObserver.ObserverId == req.ObserverId
&& x.MonitoringObserver.ElectionRoundId == req.ElectionRoundId
- && x.PollingStationId == req.PollingStationId)
+ && x.SubmissionId == req.SubmissionId)
.ExecuteUpdateAsync(x => x.SetProperty(a => a.IsDeleted, true), ct);
return TypedResults.NoContent();
diff --git a/api/src/Feature.Form.Submissions/Delete/Request.cs b/api/src/Feature.Form.Submissions/Delete/Request.cs
index 8bf509aa5..c2ba9500a 100644
--- a/api/src/Feature.Form.Submissions/Delete/Request.cs
+++ b/api/src/Feature.Form.Submissions/Delete/Request.cs
@@ -8,6 +8,5 @@ public class Request
[FromClaim(ApplicationClaimTypes.UserId)]
public Guid ObserverId { get; set; }
- public Guid PollingStationId { get; set; }
- public Guid FormId { get; set; }
+ public Guid SubmissionId { get; set; }
}
diff --git a/api/src/Feature.Form.Submissions/Delete/Validator.cs b/api/src/Feature.Form.Submissions/Delete/Validator.cs
index f18caa621..5dd929913 100644
--- a/api/src/Feature.Form.Submissions/Delete/Validator.cs
+++ b/api/src/Feature.Form.Submissions/Delete/Validator.cs
@@ -6,7 +6,6 @@ public Validator()
{
RuleFor(x => x.ElectionRoundId).NotEmpty();
RuleFor(x => x.ObserverId).NotEmpty();
- RuleFor(x => x.PollingStationId).NotEmpty();
- RuleFor(x => x.FormId).NotEmpty();
+ RuleFor(x => x.SubmissionId).NotEmpty();
}
}
diff --git a/api/src/Feature.Form.Submissions/FormSubmissionModel.cs b/api/src/Feature.Form.Submissions/FormSubmissionModel.cs
index 9abf5fff8..44660a037 100644
--- a/api/src/Feature.Form.Submissions/FormSubmissionModel.cs
+++ b/api/src/Feature.Form.Submissions/FormSubmissionModel.cs
@@ -13,6 +13,10 @@ public record FormSubmissionModel
public SubmissionFollowUpStatus FollowUpStatus { get; init; }
public IReadOnlyList Answers { get; init; }
public bool IsCompleted { get; init; }
+ public DateTime CreatedAt { get; init; }
+ public DateTime LastUpdatedAt { get; init; }
+ public int NumberOfNotes { get; init; }
+ public int NumberOfAttachments { get; init; }
public static FormSubmissionModel FromEntity(FormSubmission entity) => new()
{
@@ -23,6 +27,8 @@ public record FormSubmissionModel
.Select(AnswerMapper.ToModel)
.ToList(),
FollowUpStatus = entity.FollowUpStatus,
- IsCompleted = entity.IsCompleted
+ IsCompleted = entity.IsCompleted,
+ CreatedAt = entity.CreatedAt,
+ LastUpdatedAt = entity.LastUpdatedAt
};
}
diff --git a/api/src/Feature.Form.Submissions/GetAggregated/Endpoint.cs b/api/src/Feature.Form.Submissions/GetAggregated/Endpoint.cs
index f74e6f774..a1b98c7ea 100644
--- a/api/src/Feature.Form.Submissions/GetAggregated/Endpoint.cs
+++ b/api/src/Feature.Form.Submissions/GetAggregated/Endpoint.cs
@@ -146,9 +146,11 @@ FORM_SUBMISSIONS AS (
FROM
"Attachments" A
WHERE
- A."FormId" = FS."FormId"
+ (
+ (A."FormId" = FS."FormId" AND FS."PollingStationId" = A."PollingStationId") -- backwards compatibility
+ OR A."SubmissionId" = FS."Id"
+ )
AND A."MonitoringObserverId" = FS."MonitoringObserverId"
- AND FS."PollingStationId" = A."PollingStationId"
AND A."IsDeleted" = FALSE
AND A."IsCompleted" = TRUE
) AS "MediaFilesCount",
@@ -158,9 +160,11 @@ FORM_SUBMISSIONS AS (
FROM
"Notes" N
WHERE
- N."FormId" = FS."FormId"
+ (
+ (N."FormId" = FS."FormId" AND FS."PollingStationId" = N."PollingStationId") -- backwards compatibility
+ OR N."SubmissionId" = FS."Id"
+ )
AND N."MonitoringObserverId" = FS."MonitoringObserverId"
- AND FS."PollingStationId" = N."PollingStationId"
) AS "NotesCount",
COALESCE(
(
@@ -186,12 +190,13 @@ FORM_SUBMISSIONS AS (
FROM
"Attachments" A
WHERE
- A."ElectionRoundId" = @ELECTIONROUNDID
- AND A."FormId" = FS."FormId"
+ (
+ (A."FormId" = FS."FormId" AND FS."PollingStationId" = A."PollingStationId") -- backwards compatibility
+ OR A."SubmissionId" = FS."Id"
+ )
AND A."MonitoringObserverId" = FS."MonitoringObserverId"
AND A."IsDeleted" = FALSE
AND A."IsCompleted" = TRUE
- AND FS."PollingStationId" = A."PollingStationId"
),
'[]'::JSONB
) AS "Attachments",
@@ -213,10 +218,11 @@ FORM_SUBMISSIONS AS (
FROM
"Notes" N
WHERE
- N."ElectionRoundId" = @ELECTIONROUNDID
- AND N."FormId" = FS."FormId"
+ (
+ (N."FormId" = FS."FormId" AND FS."PollingStationId" = N."PollingStationId") -- backwards compatibility
+ OR N."SubmissionId" = FS."Id"
+ )
AND N."MonitoringObserverId" = FS."MonitoringObserverId"
- AND FS."PollingStationId" = N."PollingStationId"
),
'[]'::JSONB
) AS "Notes",
diff --git a/api/src/Feature.Form.Submissions/GetById/Endpoint.cs b/api/src/Feature.Form.Submissions/GetById/Endpoint.cs
index 360480ce3..5ddef5729 100644
--- a/api/src/Feature.Form.Submissions/GetById/Endpoint.cs
+++ b/api/src/Feature.Form.Submissions/GetById/Endpoint.cs
@@ -75,19 +75,22 @@ UNION ALL
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"
+ (
+ (A."FormId" = FS."FormId" AND FS."PollingStationId" = A."PollingStationId") -- backwards compatibility
+ OR A."SubmissionId" = FS."Id"
+ )
AND a."MonitoringObserverId" = fs."MonitoringObserverId"
- AND a."IsDeleted" = false AND a."IsCompleted" = true
- AND fs."PollingStationId" = a."PollingStationId"),'[]'::JSONB) AS "Attachments",
+ AND a."IsDeleted" = false
+ AND a."IsCompleted" = true)) 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",
+ (
+ (N."FormId" = FS."FormId" AND FS."PollingStationId" = N."PollingStationId") -- backwards compatibility
+ OR N."SubmissionId" = FS."Id"
+ )
+ AND n."MonitoringObserverId" = fs."MonitoringObserverId"), '[]'::JSONB) AS "Notes",
"LastUpdatedAt" AS "TimeSubmitted",
NULL AS "ArrivalTime",
diff --git a/api/src/Feature.Form.Submissions/GetByIdV2/Endpoint.cs b/api/src/Feature.Form.Submissions/GetByIdV2/Endpoint.cs
index bd579cead..1309e14fd 100644
--- a/api/src/Feature.Form.Submissions/GetByIdV2/Endpoint.cs
+++ b/api/src/Feature.Form.Submissions/GetByIdV2/Endpoint.cs
@@ -63,19 +63,22 @@ UNION ALL
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"
+ (
+ (A."FormId" = FS."FormId" AND FS."PollingStationId" = A."PollingStationId") -- backwards compatibility
+ OR A."SubmissionId" = FS."Id"
+ )
AND a."MonitoringObserverId" = fs."MonitoringObserverId"
- AND a."IsDeleted" = false AND a."IsCompleted" = true
- AND fs."PollingStationId" = a."PollingStationId"),'[]'::JSONB) AS "Attachments",
+ AND a."IsDeleted" = false
+ AND a."IsCompleted" = true),'[]'::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",
+ (
+ (N."FormId" = FS."FormId" AND FS."PollingStationId" = N."PollingStationId") -- backwards compatibility
+ OR N."SubmissionId" = FS."Id"
+ )
+ AND n."MonitoringObserverId" = fs."MonitoringObserverId"), '[]'::JSONB) AS "Notes",
"LastUpdatedAt" AS "TimeSubmitted",
NULL AS "ArrivalTime",
diff --git a/api/src/Feature.Form.Submissions/ListByForm/Endpoint.cs b/api/src/Feature.Form.Submissions/ListByForm/Endpoint.cs
index df0c3e76a..e1521ba69 100644
--- a/api/src/Feature.Form.Submissions/ListByForm/Endpoint.cs
+++ b/api/src/Feature.Form.Submissions/ListByForm/Endpoint.cs
@@ -59,12 +59,13 @@ FORM_SUBMISSIONS AS (
FROM
"Attachments" A
WHERE
- A."ElectionRoundId" = @ELECTIONROUNDID
- AND A."FormId" = FS."FormId"
+ (
+ (A."FormId" = FS."FormId" AND FS."PollingStationId" = A."PollingStationId") -- backwards compatibility
+ OR A."SubmissionId" = FS."Id"
+ )
AND A."MonitoringObserverId" = FS."MonitoringObserverId"
AND A."IsDeleted" = FALSE
AND A."IsCompleted" = TRUE
- AND FS."PollingStationId" = A."PollingStationId"
),
'[]'::JSONB
) AS "Attachments",
@@ -84,10 +85,11 @@ FORM_SUBMISSIONS AS (
FROM
"Notes" N
WHERE
- N."ElectionRoundId" = @ELECTIONROUNDID
- AND N."FormId" = FS."FormId"
+ (
+ (N."FormId" = FS."FormId" AND FS."PollingStationId" = N."PollingStationId") -- backwards compatibility
+ OR N."SubmissionId" = FS."Id"
+ )
AND N."MonitoringObserverId" = FS."MonitoringObserverId"
- AND FS."PollingStationId" = N."PollingStationId"
),
'[]'::JSONB
) AS "Notes",
@@ -283,7 +285,7 @@ @HASNOTES IS NULL
"GetAvailableForms" (@ELECTIONROUNDID, @NGOID, @DATASOURCE) AF
LEFT JOIN FILTERED_SUBMISSIONS FS ON FS."FormId" = AF."FormId"
WHERE
- AF."FormStatus" = 'Published'
+ AF."FormStatus" <> 'Drafted'
AND AF."FormType" NOT IN ('CitizenReporting')
GROUP BY
AF."FormId",
diff --git a/api/src/Feature.Form.Submissions/ListEntries/Endpoint.cs b/api/src/Feature.Form.Submissions/ListEntries/Endpoint.cs
index 951267d32..fd1c333b4 100644
--- a/api/src/Feature.Form.Submissions/ListEntries/Endpoint.cs
+++ b/api/src/Feature.Form.Submissions/ListEntries/Endpoint.cs
@@ -96,29 +96,41 @@ OR mo."PhoneNumber" ILIKE @searchText
AND (@hasAttachments is NULL
OR ((SELECT COUNT(1)
FROM "Attachments" A
- WHERE A."FormId" = fs."FormId"
+ WHERE
+ (
+ (A."FormId" = FS."FormId" AND FS."PollingStationId" = A."PollingStationId") -- backwards compatibility
+ OR A."SubmissionId" = FS."Id"
+ )
AND A."MonitoringObserverId" = fs."MonitoringObserverId"
- AND fs."PollingStationId" = A."PollingStationId"
AND A."IsDeleted" = false
AND A."IsCompleted" = true) = 0 AND @hasAttachments = false)
OR ((SELECT COUNT(1)
FROM "Attachments" A
- WHERE A."FormId" = fs."FormId"
+ WHERE
+ (
+ (A."FormId" = FS."FormId" AND FS."PollingStationId" = A."PollingStationId") -- backwards compatibility
+ OR A."SubmissionId" = FS."Id"
+ )
AND A."MonitoringObserverId" = fs."MonitoringObserverId"
- AND fs."PollingStationId" = A."PollingStationId"
AND A."IsDeleted" = false
AND A."IsCompleted" = true) > 0 AND @hasAttachments = true))
AND (@hasNotes is NULL
OR ((SELECT COUNT(1)
FROM "Notes" N
- WHERE N."FormId" = fs."FormId"
- AND N."MonitoringObserverId" = fs."MonitoringObserverId"
- AND fs."PollingStationId" = N."PollingStationId") = 0 AND @hasNotes = false)
+ WHERE
+ (
+ (N."FormId" = FS."FormId" AND FS."PollingStationId" = N."PollingStationId") -- backwards compatibility
+ OR N."SubmissionId" = FS."Id"
+ )
+ AND N."MonitoringObserverId" = fs."MonitoringObserverId") = 0 AND @hasNotes = false)
OR ((SELECT COUNT(1)
FROM "Notes" N
- WHERE N."FormId" = fs."FormId"
- AND N."MonitoringObserverId" = fs."MonitoringObserverId"
- AND fs."PollingStationId" = N."PollingStationId") > 0 AND @hasNotes = true))
+ WHERE
+ (
+ (N."FormId" = FS."FormId" AND FS."PollingStationId" = N."PollingStationId") -- backwards compatibility
+ OR N."SubmissionId" = FS."Id"
+ )
+ AND N."MonitoringObserverId" = fs."MonitoringObserverId") > 0 AND @hasNotes = true))
AND (@fromDate is NULL OR FS."LastUpdatedAt" >= @fromDate::timestamp)
AND (@toDate is NULL OR FS."LastUpdatedAt" <= @toDate::timestamp)) c;
@@ -181,9 +193,12 @@ form_submissions AS (SELECT fs."Id"
AND A."IsCompleted" = true) AS "MediaFilesCount",
(SELECT COUNT(1)
FROM "Notes" N
- WHERE N."FormId" = fs."FormId"
- AND N."MonitoringObserverId" = fs."MonitoringObserverId"
- AND fs."PollingStationId" = N."PollingStationId") AS "NotesCount",
+ WHERE
+ (
+ (N."FormId" = FS."FormId" AND FS."PollingStationId" = N."PollingStationId") -- backwards compatibility
+ OR N."SubmissionId" = FS."Id"
+ )
+ AND N."MonitoringObserverId" = fs."MonitoringObserverId") AS "NotesCount",
fs."LastUpdatedAt" AS "TimeSubmitted",
fs."FollowUpStatus",
f."DefaultLanguage",
diff --git a/api/src/Feature.Form.Submissions/ListMy/Endpoint.cs b/api/src/Feature.Form.Submissions/ListMy/Endpoint.cs
index 5c042139a..16fe86cdd 100644
--- a/api/src/Feature.Form.Submissions/ListMy/Endpoint.cs
+++ b/api/src/Feature.Form.Submissions/ListMy/Endpoint.cs
@@ -1,6 +1,10 @@
-namespace Feature.Form.Submissions.ListMy;
+using Microsoft.EntityFrameworkCore;
+using Module.Answers.Mappers;
+using Vote.Monitor.Domain;
-public class Endpoint(IAuthorizationService authorizationService, IReadRepository repository)
+namespace Feature.Form.Submissions.ListMy;
+
+public class Endpoint(IAuthorizationService authorizationService, VoteMonitorContext context)
: Endpoint, NotFound>>
{
public override void Configure()
@@ -26,13 +30,45 @@ public override async Task, NotFound>> ExecuteAsync(Request
return TypedResults.NotFound();
}
- var specification =
- new GetFormSubmissionForObserverSpecification(req.ElectionRoundId, req.ObserverId, req.PollingStationIds);
- var submissions = await repository.ListAsync(specification, ct);
+
+ var submissions = await context.FormSubmissions.Select(fs => new
+ {
+ fs.Id,
+ fs.PollingStationId,
+ fs.FormId,
+ fs.Answers,
+ fs.FollowUpStatus,
+ fs.IsCompleted,
+ fs.CreatedAt,
+ fs.LastUpdatedAt,
+ NumberOfAttachments =
+ context.Attachments.Count(a =>
+ a.SubmissionId == fs.Id && a.MonitoringObserverId == fs.MonitoringObserverId &&
+ a.IsCompleted && !a.IsDeleted),
+ NumberOfNotes = context.Notes.Count(n =>
+ n.SubmissionId == fs.Id && n.MonitoringObserverId == fs.MonitoringObserverId),
+ }).AsNoTracking()
+ .ToListAsync(ct);
+
return TypedResults.Ok(new Response
{
- Submissions = submissions
+ Submissions = submissions.Select(entity => new FormSubmissionModel(){
+ Id = entity.Id,
+ PollingStationId = entity.PollingStationId,
+ FormId = entity.FormId,
+ Answers = entity.Answers
+ .Select(AnswerMapper.ToModel)
+ .ToList(),
+ FollowUpStatus = entity.FollowUpStatus,
+ IsCompleted = entity.IsCompleted,
+ CreatedAt = entity.CreatedAt,
+ LastUpdatedAt = entity.LastUpdatedAt,
+ NumberOfAttachments= entity.NumberOfAttachments,
+ NumberOfNotes= entity.NumberOfNotes,
+
+
+ }).ToList()
});
}
-}
\ No newline at end of file
+}
diff --git a/api/src/Feature.Form.Submissions/Specifications/GetFormSubmissionForObserverSpecification.cs b/api/src/Feature.Form.Submissions/Specifications/GetFormSubmissionForObserverSpecification.cs
deleted file mode 100644
index e77544bcb..000000000
--- a/api/src/Feature.Form.Submissions/Specifications/GetFormSubmissionForObserverSpecification.cs
+++ /dev/null
@@ -1,19 +0,0 @@
-using Ardalis.Specification;
-
-namespace Feature.Form.Submissions.Specifications;
-
-public sealed class GetFormSubmissionForObserverSpecification : Specification
-{
- public GetFormSubmissionForObserverSpecification(Guid electionRoundId, Guid observerId,
- List? pollingStationIds)
- {
- Query.Where(x =>
- x.ElectionRoundId == electionRoundId
- && x.MonitoringObserver.ElectionRoundId == electionRoundId
- && x.MonitoringObserver.ObserverId == observerId)
- .Where(x => pollingStationIds.Contains(x.PollingStationId),
- pollingStationIds != null && pollingStationIds.Any());
-
- Query.Select(x => FormSubmissionModel.FromEntity(x));
- }
-}
diff --git a/api/src/Feature.Form.Submissions/Specifications/GetFormSubmissionSpecification.cs b/api/src/Feature.Form.Submissions/Specifications/GetFormSubmissionSpecification.cs
index 5cf81f325..9ef61d7ed 100644
--- a/api/src/Feature.Form.Submissions/Specifications/GetFormSubmissionSpecification.cs
+++ b/api/src/Feature.Form.Submissions/Specifications/GetFormSubmissionSpecification.cs
@@ -4,6 +4,7 @@ namespace Feature.Form.Submissions.Specifications;
public sealed class GetFormSubmissionSpecification : SingleResultSpecification
{
+ [Obsolete("Will be removed in future version")]
public GetFormSubmissionSpecification(Guid electionRoundId, Guid pollingStationId, Guid formId, Guid observerId)
{
Query.Where(x =>
@@ -15,12 +16,15 @@ public GetFormSubmissionSpecification(Guid electionRoundId, Guid pollingStationI
&& x.FormId == formId);
}
- public GetFormSubmissionSpecification(Guid electionRoundId, Guid ngoId, Guid submissionId)
+ public GetFormSubmissionSpecification(Guid electionRoundId,Guid pollingStationId, Guid formId, Guid observerId, Guid submissionId)
{
Query.Where(x =>
x.ElectionRoundId == electionRoundId
- && x.MonitoringObserver.MonitoringNgo.ElectionRoundId == electionRoundId
- && x.MonitoringObserver.MonitoringNgo.NgoId == ngoId
+ && x.MonitoringObserver.ObserverId == observerId
+ && x.MonitoringObserver.ElectionRoundId == electionRoundId
+ && x.PollingStationId == pollingStationId
+ && x.Form.ElectionRoundId == electionRoundId
+ && x.FormId == formId
&& x.Id == submissionId);
}
}
diff --git a/api/src/Feature.Form.Submissions/Upsert/Endpoint.cs b/api/src/Feature.Form.Submissions/Upsert/Endpoint.cs
index f61d68509..f80c49db3 100644
--- a/api/src/Feature.Form.Submissions/Upsert/Endpoint.cs
+++ b/api/src/Feature.Form.Submissions/Upsert/Endpoint.cs
@@ -8,6 +8,7 @@
namespace Feature.Form.Submissions.Upsert;
+[Obsolete("Will be removed in future version")]
public class Endpoint(
IRepository repository,
IReadRepository pollingStationRepository,
@@ -111,7 +112,7 @@ private async Task, NotFound>> AddFormSubmission
}
var submission = form.CreateFormSubmission(pollingStation, monitoringObserver, answers, req.IsCompleted,
- req.LastUpdatedAt ?? timeProvider.UtcNow);
+ req.CreatedAt ?? timeProvider.UtcNow, req.LastUpdatedAt ?? timeProvider.UtcNow);
await repository.AddAsync(submission, ct);
return TypedResults.Ok(FormSubmissionModel.FromEntity(submission));
diff --git a/api/src/Feature.Form.Submissions/Upsert/Request.cs b/api/src/Feature.Form.Submissions/Upsert/Request.cs
index a1a81cf4a..d17460212 100644
--- a/api/src/Feature.Form.Submissions/Upsert/Request.cs
+++ b/api/src/Feature.Form.Submissions/Upsert/Request.cs
@@ -20,4 +20,9 @@ public class Request
/// Temporary made nullable until we release a mobile version that will always send this property.
///
public DateTime? LastUpdatedAt { get; set; }
+
+ ///
+ /// Temporary made nullable until we release a mobile version that will always send this property.
+ ///
+ public DateTime? CreatedAt { get; set; }
}
diff --git a/api/src/Feature.Form.Submissions/UpsertV2/Endpoint.cs b/api/src/Feature.Form.Submissions/UpsertV2/Endpoint.cs
new file mode 100644
index 000000000..55be21760
--- /dev/null
+++ b/api/src/Feature.Form.Submissions/UpsertV2/Endpoint.cs
@@ -0,0 +1,130 @@
+using Module.Answers.Mappers;
+using Vote.Monitor.Core.Services.Time;
+using Vote.Monitor.Domain.Entities.CoalitionAggregate;
+using Vote.Monitor.Domain.Entities.FormAggregate;
+using Vote.Monitor.Domain.Entities.FormAnswerBase;
+using Vote.Monitor.Domain.Entities.FormAnswerBase.Answers;
+using Vote.Monitor.Domain.Entities.MonitoringObserverAggregate;
+
+namespace Feature.Form.Submissions.UpsertV2;
+
+public class Endpoint(
+ IRepository repository,
+ IReadRepository pollingStationRepository,
+ IReadRepository monitoringObserverRepository,
+ IReadRepository coalitionRepository,
+ IReadRepository formRepository,
+ IAuthorizationService authorizationService,
+ ITimeProvider timeProvider) : Endpoint, NotFound>>
+{
+ public override void Configure()
+ {
+ Post("/api/election-rounds/{electionRoundId}/form-submissions/{id}");
+ DontAutoTag();
+ Options(x => x.WithTags("form-submissions", "mobile"));
+ Summary(s =>
+ {
+ s.Summary = "Upserts form submission";
+ s.Description = "When updating a submission it will update only the properties that are not null";
+ });
+
+ Policies(PolicyNames.ObserversOnly);
+ }
+
+ public override async Task, NotFound>> ExecuteAsync(Request req,
+ CancellationToken ct)
+ {
+ var authorizationResult =
+ await authorizationService.AuthorizeAsync(User, new MonitoringObserverRequirement(req.ElectionRoundId));
+ if (!authorizationResult.Succeeded)
+ {
+ return TypedResults.NotFound();
+ }
+
+ var coalitionFormSpecification =
+ new GetCoalitionFormSpecification(req.ElectionRoundId, req.ObserverId, req.FormId);
+ var ngoFormSpecification =
+ new GetMonitoringNgoFormSpecification(req.ElectionRoundId, req.ObserverId, req.FormId);
+
+ var form = (await coalitionRepository.FirstOrDefaultAsync(coalitionFormSpecification, ct)) ??
+ (await formRepository.FirstOrDefaultAsync(ngoFormSpecification, ct));
+ if (form is null)
+ {
+ return TypedResults.NotFound();
+ }
+
+ if (form.Status == FormStatus.Drafted)
+ {
+ AddError(x => x.FormId, "Form is drafted");
+ ThrowIfAnyErrors();
+ }
+
+ var specification =
+ new GetFormSubmissionSpecification(req.ElectionRoundId, req.PollingStationId, req.FormId, req.ObserverId,
+ req.Id);
+ var formSubmission = await repository.FirstOrDefaultAsync(specification, ct);
+
+ List? answers = null;
+ if (req.Answers != null)
+ {
+ answers = req.Answers.Select(AnswerMapper.ToEntity).ToList();
+
+ ValidateAnswers(answers, form);
+ }
+
+ return formSubmission is null
+ ? await AddFormSubmissionAsync(req, form, answers, ct)
+ : await UpdateFormSubmissionAsync(form, formSubmission, answers, req.IsCompleted, req.LastUpdatedAt, ct);
+ }
+
+ private async Task, NotFound>> UpdateFormSubmissionAsync(FormAggregate form,
+ FormSubmission submission,
+ List? answers,
+ bool? isCompleted,
+ DateTime? lastUpdatedAt,
+ CancellationToken ct)
+ {
+ submission = form.FillIn(submission, answers, isCompleted, lastUpdatedAt ?? timeProvider.UtcNow);
+ await repository.UpdateAsync(submission, ct);
+
+ return TypedResults.Ok(FormSubmissionModel.FromEntity(submission));
+ }
+
+ private async Task, NotFound>> AddFormSubmissionAsync(Request req,
+ FormAggregate form,
+ List? answers,
+ CancellationToken ct)
+ {
+ var pollingStationSpecification = new GetPollingStationSpecification(req.ElectionRoundId, req.PollingStationId);
+ var pollingStation = await pollingStationRepository.FirstOrDefaultAsync(pollingStationSpecification, ct);
+ if (pollingStation is null)
+ {
+ return TypedResults.NotFound();
+ }
+
+ var monitoringObserverSpecification =
+ new GetMonitoringObserverSpecification(req.ElectionRoundId, req.ObserverId);
+ var monitoringObserver =
+ await monitoringObserverRepository.FirstOrDefaultAsync(monitoringObserverSpecification, ct);
+ if (monitoringObserver is null)
+ {
+ return TypedResults.NotFound();
+ }
+
+ var submission = form.CreateFormSubmissionV2(req.Id, pollingStation, monitoringObserver, answers, req.IsCompleted,
+ req.CreatedAt, req.LastUpdatedAt);
+ await repository.AddAsync(submission, ct);
+
+ return TypedResults.Ok(FormSubmissionModel.FromEntity(submission));
+ }
+
+ private void ValidateAnswers(List answers, FormAggregate form)
+ {
+ var validationResult = AnswersValidator.GetValidationResults(answers, form.Questions);
+ if (!validationResult.IsValid)
+ {
+ ValidationFailures.AddRange(validationResult.Errors);
+ ThrowIfAnyErrors();
+ }
+ }
+}
diff --git a/api/src/Feature.Form.Submissions/UpsertV2/Request.cs b/api/src/Feature.Form.Submissions/UpsertV2/Request.cs
new file mode 100644
index 000000000..beb2ca554
--- /dev/null
+++ b/api/src/Feature.Form.Submissions/UpsertV2/Request.cs
@@ -0,0 +1,22 @@
+using Module.Answers.Requests;
+using Vote.Monitor.Core.Security;
+
+namespace Feature.Form.Submissions.UpsertV2;
+
+public class Request
+{
+ public Guid Id { get; set; }
+ public Guid ElectionRoundId { get; set; }
+ public Guid PollingStationId { get; set; }
+
+ [FromClaim(ApplicationClaimTypes.UserId)]
+ public Guid ObserverId { get; set; }
+
+ public Guid FormId { get; set; }
+
+ public List? Answers { get; set; }
+ public bool? IsCompleted { get; set; }
+
+ public DateTime CreatedAt { get; set; }
+ public DateTime LastUpdatedAt { get; set; }
+}
diff --git a/api/src/Feature.Form.Submissions/UpsertV2/Validator.cs b/api/src/Feature.Form.Submissions/UpsertV2/Validator.cs
new file mode 100644
index 000000000..31257b8e6
--- /dev/null
+++ b/api/src/Feature.Form.Submissions/UpsertV2/Validator.cs
@@ -0,0 +1,35 @@
+using Module.Answers.Validators;
+
+namespace Feature.Form.Submissions.UpsertV2;
+
+public class Validator : Validator
+{
+ public Validator()
+ {
+ RuleFor(x => x.ElectionRoundId).NotEmpty();
+ RuleFor(x => x.PollingStationId).NotEmpty();
+ RuleFor(x => x.ObserverId).NotEmpty();
+ RuleFor(x => x.FormId).NotEmpty();
+ RuleFor(x => x.Id).NotEmpty();
+
+ RuleForEach(x => x.Answers)
+ .SetInheritanceValidator(v =>
+ {
+ v.Add(new RatingAnswerRequestValidator());
+ v.Add(new MultiSelectAnswerRequestValidator());
+ v.Add(new SingleSelectAnswerRequestValidator());
+ v.Add(new DateAnswerRequestValidator());
+ v.Add(new NumberAnswerRequestValidator());
+ v.Add(new TextAnswerRequestValidator());
+ });
+
+ RuleFor(x => x.LastUpdatedAt)
+ .Must(BeUtc)
+ .WithMessage("LastUpdatedAt must be in UTC format.");
+ }
+
+ private bool BeUtc(DateTime date)
+ {
+ return date.Kind == DateTimeKind.Utc;
+ }
+}
diff --git a/api/src/Feature.MonitoringObservers/Get/Endpoint.cs b/api/src/Feature.MonitoringObservers/Get/Endpoint.cs
index 3538ecf54..2fd230015 100644
--- a/api/src/Feature.MonitoringObservers/Get/Endpoint.cs
+++ b/api/src/Feature.MonitoringObservers/Get/Endpoint.cs
@@ -67,8 +67,7 @@ UNION ALL
FROM
"Notes" N
WHERE
- N."ElectionRoundId" = @electionRoundId
- AND N."MonitoringObserverId" = (SELECT "Id" FROM MONITORINGOBSERVER)
+ N."MonitoringObserverId" = (SELECT "Id" FROM MONITORINGOBSERVER)
UNION ALL
@@ -76,8 +75,9 @@ UNION ALL
MAX(A."LastUpdatedAt") AS "LatestActivityAt"
FROM
"Attachments" A
+ INNER JOIN "MonitoringObservers" MO ON A."MonitoringObserverId" = MO."Id"
WHERE
- A."ElectionRoundId" = @electionRoundId
+ MO."ElectionRoundId" = @electionRoundId
AND A."MonitoringObserverId" = (SELECT "Id" FROM MONITORINGOBSERVER)
UNION ALL
diff --git a/api/src/Feature.MonitoringObservers/List/Endpoint.cs b/api/src/Feature.MonitoringObservers/List/Endpoint.cs
index 0bca432e8..e887d5c42 100644
--- a/api/src/Feature.MonitoringObservers/List/Endpoint.cs
+++ b/api/src/Feature.MonitoringObservers/List/Endpoint.cs
@@ -88,8 +88,9 @@ UNION ALL
MAX(N."LastUpdatedAt") AS "LatestActivityAt"
FROM
"Notes" N
+ INNER JOIN "MonitoringObservers" MO ON N."MonitoringObserverId" = MO."Id"
WHERE
- N."ElectionRoundId" = @electionRoundId
+ MO."ElectionRoundId" = @electionRoundId
GROUP BY
N."MonitoringObserverId"
UNION ALL
@@ -98,8 +99,9 @@ UNION ALL
MAX(A."LastUpdatedAt") AS "LatestActivityAt"
FROM
"Attachments" A
+ INNER JOIN "MonitoringObservers" MO ON A."MonitoringObserverId" = MO."Id"
WHERE
- A."ElectionRoundId" = @electionRoundId
+ MO."ElectionRoundId" = @electionRoundId
GROUP BY
A."MonitoringObserverId"
UNION ALL
diff --git a/api/src/Feature.Notes/Delete/Endpoint.cs b/api/src/Feature.Notes/Delete/Endpoint.cs
index 478682d55..29c66707b 100644
--- a/api/src/Feature.Notes/Delete/Endpoint.cs
+++ b/api/src/Feature.Notes/Delete/Endpoint.cs
@@ -4,6 +4,8 @@
namespace Feature.Notes.Delete;
+[Obsolete("Will be removed in future version")]
+
public class Endpoint(
IAuthorizationService authorizationService,
IRepository repository)
diff --git a/api/src/Feature.Notes/DeleteV2/Endpoint.cs b/api/src/Feature.Notes/DeleteV2/Endpoint.cs
new file mode 100644
index 000000000..aad8b9798
--- /dev/null
+++ b/api/src/Feature.Notes/DeleteV2/Endpoint.cs
@@ -0,0 +1,40 @@
+using Authorization.Policies.Requirements;
+using Feature.Notes.Specifications;
+using Microsoft.AspNetCore.Authorization;
+
+namespace Feature.Notes.DeleteV2;
+
+public class Endpoint(
+ IAuthorizationService authorizationService,
+ IRepository repository)
+ : Endpoint>>
+{
+ public override void Configure()
+ {
+ Delete("/api/election-rounds/{electionRoundId}/form-submissions/{submissionId}/notes/{id}");
+ DontAutoTag();
+ Options(x => x.WithTags("notes", "mobile"));
+ Summary(s => {
+ s.Summary = "Deletes a note";
+ });
+ }
+
+ public override async Task>> ExecuteAsync(Request req, CancellationToken ct)
+ {
+ var authorizationResult = await authorizationService.AuthorizeAsync(User, new MonitoringObserverRequirement(req.ElectionRoundId));
+ if (!authorizationResult.Succeeded)
+ {
+ return TypedResults.NotFound();
+ }
+
+ var note = await repository.FirstOrDefaultAsync(new GetNoteByIdSpecification(req.ElectionRoundId, req.SubmissionId, req.ObserverId, req.Id), ct);
+ if (note == null)
+ {
+ return TypedResults.NotFound();
+ }
+
+ await repository.DeleteAsync(note, ct);
+
+ return TypedResults.NoContent();
+ }
+}
diff --git a/api/src/Feature.Notes/DeleteV2/Request.cs b/api/src/Feature.Notes/DeleteV2/Request.cs
new file mode 100644
index 000000000..675e10543
--- /dev/null
+++ b/api/src/Feature.Notes/DeleteV2/Request.cs
@@ -0,0 +1,13 @@
+using Vote.Monitor.Core.Security;
+
+namespace Feature.Notes.DeleteV2;
+
+public class Request
+{
+ public Guid ElectionRoundId { get; set; }
+ public Guid SubmissionId { get; set; }
+
+ [FromClaim(ApplicationClaimTypes.UserId)]
+ public Guid ObserverId { get; set; }
+ public Guid Id { get; set; }
+}
diff --git a/api/src/Feature.Notes/DeleteV2/Validator.cs b/api/src/Feature.Notes/DeleteV2/Validator.cs
new file mode 100644
index 000000000..b85970640
--- /dev/null
+++ b/api/src/Feature.Notes/DeleteV2/Validator.cs
@@ -0,0 +1,12 @@
+namespace Feature.Notes.DeleteV2;
+
+public class Validator : Validator
+{
+ public Validator()
+ {
+ RuleFor(x => x.ElectionRoundId).NotEmpty();
+ RuleFor(x => x.SubmissionId).NotEmpty();
+ RuleFor(x => x.ObserverId).NotEmpty();
+ RuleFor(x => x.Id).NotEmpty();
+ }
+}
diff --git a/api/src/Feature.Notes/Get/Endpoint.cs b/api/src/Feature.Notes/Get/Endpoint.cs
index fa302dbb2..c9148676a 100644
--- a/api/src/Feature.Notes/Get/Endpoint.cs
+++ b/api/src/Feature.Notes/Get/Endpoint.cs
@@ -34,15 +34,6 @@ public override async Task, BadRequest, No
return TypedResults.NotFound();
}
- return TypedResults.Ok(new NoteModel
- {
- Id = note.Id,
- ElectionRoundId = note.ElectionRoundId,
- PollingStationId = note.PollingStationId,
- FormId = note.FormId,
- QuestionId = note.QuestionId,
- Text = note.Text,
- LastUpdatedAt = note.LastUpdatedAt
- });
+ return TypedResults.Ok(NoteModel.FromEntity(note));
}
}
diff --git a/api/src/Feature.Notes/List/Endpoint.cs b/api/src/Feature.Notes/List/Endpoint.cs
index 2c2344d87..bd07f52e9 100644
--- a/api/src/Feature.Notes/List/Endpoint.cs
+++ b/api/src/Feature.Notes/List/Endpoint.cs
@@ -31,16 +31,7 @@ public override async Task>, NotFound, BadRequest new NoteModel
- {
- Id = note.Id,
- ElectionRoundId = note.ElectionRoundId,
- PollingStationId = note.PollingStationId,
- FormId = note.FormId,
- QuestionId = note.QuestionId,
- Text = note.Text,
- LastUpdatedAt = note.LastUpdatedAt
- })
+ .Select(NoteModel.FromEntity)
.ToList()
);
}
diff --git a/api/src/Feature.Notes/List/Response.cs b/api/src/Feature.Notes/List/Response.cs
deleted file mode 100644
index 795022921..000000000
--- a/api/src/Feature.Notes/List/Response.cs
+++ /dev/null
@@ -1,6 +0,0 @@
-namespace Feature.Notes.List;
-
-public record Response
-{
- public required List Notes { get; init; }
-}
diff --git a/api/src/Feature.Notes/ListV2/Endpoint.cs b/api/src/Feature.Notes/ListV2/Endpoint.cs
new file mode 100644
index 000000000..b3b6e8f8c
--- /dev/null
+++ b/api/src/Feature.Notes/ListV2/Endpoint.cs
@@ -0,0 +1,41 @@
+using Authorization.Policies.Requirements;
+using Feature.Notes.Specifications;
+using Microsoft.AspNetCore.Authorization;
+
+namespace Feature.Notes.ListV2;
+
+public class Endpoint(
+ IAuthorizationService authorizationService,
+ IReadRepository repository)
+ : Endpoint>, NotFound, BadRequest>>
+{
+ public override void Configure()
+ {
+ Get("/api/election-rounds/{electionRoundId}/form-submissions/{submissionId}/notes");
+ DontAutoTag();
+ Options(x => x.WithTags("notes", "mobile"));
+ Summary(s =>
+ {
+ s.Summary = "Lists notes for a submission";
+ });
+ }
+
+ public override async Task>, NotFound, BadRequest>> ExecuteAsync(
+ Request req, CancellationToken ct)
+ {
+ var authorizationResult =
+ await authorizationService.AuthorizeAsync(User, new MonitoringObserverRequirement(req.ElectionRoundId));
+ if (!authorizationResult.Succeeded)
+ {
+ return TypedResults.NotFound();
+ }
+
+ var specification = new GetNotesV2Specification(req.ElectionRoundId, req.ObserverId, req.SubmissionId);
+ var notes = await repository.ListAsync(specification, ct);
+
+ return TypedResults.Ok(notes
+ .Select(note => NoteModelV2.FromEntity(req.ElectionRoundId, note))
+ .ToList()
+ );
+ }
+}
diff --git a/api/src/Feature.Notes/ListV2/Request.cs b/api/src/Feature.Notes/ListV2/Request.cs
new file mode 100644
index 000000000..f50977755
--- /dev/null
+++ b/api/src/Feature.Notes/ListV2/Request.cs
@@ -0,0 +1,13 @@
+using Vote.Monitor.Core.Security;
+
+namespace Feature.Notes.ListV2;
+
+public class Request
+{
+ public Guid ElectionRoundId { get; set; }
+
+
+ [FromClaim(ApplicationClaimTypes.UserId)]
+ public Guid ObserverId { get; set; }
+ public Guid SubmissionId { get; set; }
+}
diff --git a/api/src/Feature.Notes/ListV2/Validator.cs b/api/src/Feature.Notes/ListV2/Validator.cs
new file mode 100644
index 000000000..fde6a9d75
--- /dev/null
+++ b/api/src/Feature.Notes/ListV2/Validator.cs
@@ -0,0 +1,11 @@
+namespace Feature.Notes.ListV2;
+
+public class Validator : Validator
+{
+ public Validator()
+ {
+ RuleFor(x => x.ElectionRoundId).NotEmpty();
+ RuleFor(x => x.ObserverId).NotEmpty();
+ RuleFor(x => x.SubmissionId).NotEmpty();
+ }
+}
diff --git a/api/src/Feature.Notes/NoteModel.cs b/api/src/Feature.Notes/NoteModel.cs
index d5724f612..d1c283b2c 100644
--- a/api/src/Feature.Notes/NoteModel.cs
+++ b/api/src/Feature.Notes/NoteModel.cs
@@ -1,5 +1,6 @@
namespace Feature.Notes;
+[Obsolete("Will be removed in future version")]
public record NoteModel
{
public required Guid Id { get; init; }
@@ -19,6 +20,6 @@ public static NoteModel FromEntity(NoteAggregate note)
FormId = note.FormId,
QuestionId = note.QuestionId,
Text = note.Text,
- LastUpdatedAt = note.LastUpdatedAt
+ LastUpdatedAt = note.LastUpdatedAt,
};
}
diff --git a/api/src/Feature.Notes/NoteModelV2.cs b/api/src/Feature.Notes/NoteModelV2.cs
new file mode 100644
index 000000000..5f042c8a1
--- /dev/null
+++ b/api/src/Feature.Notes/NoteModelV2.cs
@@ -0,0 +1,22 @@
+namespace Feature.Notes;
+
+public record NoteModelV2
+{
+ public required Guid Id { get; init; }
+ public required Guid ElectionRoundId { get; init; }
+ public required Guid SubmissionId { get; init; }
+ public required Guid QuestionId { get; init; }
+ public required string Text { get; init; }
+ public required DateTime LastUpdatedAt { get; init; }
+
+ public static NoteModelV2 FromEntity(Guid electionRoundId, NoteAggregate note)
+ => new ()
+ {
+ Id = note.Id,
+ ElectionRoundId = electionRoundId,
+ SubmissionId = note.SubmissionId,
+ QuestionId = note.QuestionId,
+ Text = note.Text,
+ LastUpdatedAt = note.LastUpdatedAt,
+ };
+}
diff --git a/api/src/Feature.Notes/Specifications/GetNoteByIdSpecification.cs b/api/src/Feature.Notes/Specifications/GetNoteByIdSpecification.cs
index 028f87fe1..f96c3100e 100644
--- a/api/src/Feature.Notes/Specifications/GetNoteByIdSpecification.cs
+++ b/api/src/Feature.Notes/Specifications/GetNoteByIdSpecification.cs
@@ -4,6 +4,7 @@ namespace Feature.Notes.Specifications;
public sealed class GetNoteByIdSpecification : SingleResultSpecification
{
+ [Obsolete("Will be removed in future version.")]
public GetNoteByIdSpecification(Guid electionRoundId, Guid observerId, Guid id)
{
Query
@@ -12,4 +13,13 @@ public GetNoteByIdSpecification(Guid electionRoundId, Guid observerId, Guid id)
&& x.Id == id
&& x.MonitoringObserver.ElectionRoundId == electionRoundId);
}
+
+ public GetNoteByIdSpecification(Guid electionRoundId, Guid submissionId, Guid observerId, Guid id)
+ {
+ Query
+ .Where(x => x.SubmissionId == submissionId
+ && x.MonitoringObserver.ObserverId == observerId
+ && x.Id == id
+ && x.MonitoringObserver.ElectionRoundId == electionRoundId);
+ }
}
diff --git a/api/src/Feature.Notes/Specifications/GetNotesSpecification.cs b/api/src/Feature.Notes/Specifications/GetNotesSpecification.cs
index f6f1eb9b3..d1d509995 100644
--- a/api/src/Feature.Notes/Specifications/GetNotesSpecification.cs
+++ b/api/src/Feature.Notes/Specifications/GetNotesSpecification.cs
@@ -11,7 +11,6 @@ public GetNotesSpecification(Guid electionRoundId, Guid pollingStationId, Guid o
&& x.PollingStationId == pollingStationId
&& x.MonitoringObserver.ObserverId == observerId
&& x.MonitoringObserver.ElectionRoundId == electionRoundId
- && x.Form.ElectionRoundId == electionRoundId
&& x.FormId == formId);
}
}
diff --git a/api/src/Feature.Notes/Specifications/GetNotesV2Specification.cs b/api/src/Feature.Notes/Specifications/GetNotesV2Specification.cs
new file mode 100644
index 000000000..1ded1c8f4
--- /dev/null
+++ b/api/src/Feature.Notes/Specifications/GetNotesV2Specification.cs
@@ -0,0 +1,15 @@
+using Ardalis.Specification;
+
+namespace Feature.Notes.Specifications;
+
+public sealed class GetNotesV2Specification : Specification
+{
+ public GetNotesV2Specification(Guid electionRoundId, Guid observerId, Guid submissionId)
+ {
+ Query
+ .Where(x =>
+ x.MonitoringObserver.ObserverId == observerId
+ && x.MonitoringObserver.ElectionRoundId == electionRoundId
+ && x.SubmissionId == submissionId);
+ }
+}
diff --git a/api/src/Feature.Notes/Upsert/Endpoint.cs b/api/src/Feature.Notes/Upsert/Endpoint.cs
index 51e46bfd5..c896d1349 100644
--- a/api/src/Feature.Notes/Upsert/Endpoint.cs
+++ b/api/src/Feature.Notes/Upsert/Endpoint.cs
@@ -6,6 +6,7 @@
namespace Feature.Notes.Upsert;
+[Obsolete("Will be removed in future version")]
public class Endpoint(
IAuthorizationService authorizationService,
IRepository monitoringObserverRepository,
diff --git a/api/src/Feature.Notes/UpsertV2/Endpoint.cs b/api/src/Feature.Notes/UpsertV2/Endpoint.cs
new file mode 100644
index 000000000..699d0d8b0
--- /dev/null
+++ b/api/src/Feature.Notes/UpsertV2/Endpoint.cs
@@ -0,0 +1,66 @@
+using Authorization.Policies.Requirements;
+using Feature.Notes.Specifications;
+using Microsoft.AspNetCore.Authorization;
+using Vote.Monitor.Domain.Entities.MonitoringObserverAggregate;
+
+namespace Feature.Notes.UpsertV2;
+
+public class Endpoint(
+ IAuthorizationService authorizationService,
+ IRepository monitoringObserverRepository,
+ IRepository repository)
+ : Endpoint, NotFound>>
+{
+ public override void Configure()
+ {
+ Post("/api/election-rounds/{electionRoundId}/form-submissions/{submissionId}/notes/{id}");
+ DontAutoTag();
+ Options(x => x.WithTags("notes", "mobile"));
+ Summary(s =>
+ {
+ s.Summary = "Upserts a note for a question in a form submission";
+ });
+ }
+
+ public override async Task, NotFound>> ExecuteAsync(Request req, CancellationToken ct)
+ {
+ var authorizationResult =
+ await authorizationService.AuthorizeAsync(User, new MonitoringObserverRequirement(req.ElectionRoundId));
+ if (!authorizationResult.Succeeded)
+ {
+ return TypedResults.NotFound();
+ }
+
+ var note = await repository.FirstOrDefaultAsync(
+ new GetNoteByIdSpecification(req.ElectionRoundId, req.SubmissionId,req.ObserverId, req.Id), ct);
+ return note == null ? await AddNoteAsync(req, ct) : await UpdateNoteAsync(note, req, ct);
+ }
+
+ private async Task, NotFound>> UpdateNoteAsync(NoteAggregate note, Request req,
+ CancellationToken ct)
+ {
+ note.UpdateText(req.Text, req.LastUpdatedAt);
+ await repository.UpdateAsync(note, ct);
+
+ return TypedResults.Ok(NoteModelV2.FromEntity(req.ElectionRoundId, note));
+ }
+
+ private async Task, NotFound>> AddNoteAsync(Request req, CancellationToken ct)
+ {
+ var monitoringObserverSpecification =
+ new GetMonitoringObserverIdSpecification(req.ElectionRoundId, req.ObserverId);
+ var monitoringObserverId =
+ await monitoringObserverRepository.FirstOrDefaultAsync(monitoringObserverSpecification, ct);
+
+ var note = NoteAggregate.Create(req.Id,
+ monitoringObserverId,
+ req.SubmissionId,
+ req.QuestionId,
+ req.Text,
+ req.LastUpdatedAt);
+
+ await repository.AddAsync(note, ct);
+
+ return TypedResults.Ok(NoteModelV2.FromEntity(req.ElectionRoundId, note));
+ }
+}
diff --git a/api/src/Feature.Notes/UpsertV2/Request.cs b/api/src/Feature.Notes/UpsertV2/Request.cs
new file mode 100644
index 000000000..c3d1f399d
--- /dev/null
+++ b/api/src/Feature.Notes/UpsertV2/Request.cs
@@ -0,0 +1,17 @@
+using Vote.Monitor.Core.Security;
+
+namespace Feature.Notes.UpsertV2;
+
+public class Request
+{
+ public Guid ElectionRoundId { get; set; }
+
+
+ [FromClaim(ApplicationClaimTypes.UserId)]
+ public Guid ObserverId { get; set; }
+ public Guid SubmissionId { get; set; }
+ public Guid QuestionId { get; set; }
+ public Guid Id { get; set; }
+ public string Text { get; set; } = string.Empty;
+ public DateTime LastUpdatedAt { get; set; }
+}
diff --git a/api/src/Feature.Notes/UpsertV2/Validator.cs b/api/src/Feature.Notes/UpsertV2/Validator.cs
new file mode 100644
index 000000000..0768ab557
--- /dev/null
+++ b/api/src/Feature.Notes/UpsertV2/Validator.cs
@@ -0,0 +1,14 @@
+namespace Feature.Notes.UpsertV2;
+
+public class Validator : Validator
+{
+ public Validator()
+ {
+ RuleFor(x => x.ElectionRoundId).NotEmpty();
+ RuleFor(x => x.ObserverId).NotEmpty();
+ RuleFor(x => x.Id).NotEmpty();
+ RuleFor(x => x.SubmissionId).NotEmpty();
+ RuleFor(x => x.QuestionId).NotEmpty();
+ RuleFor(x => x.Text).NotEmpty().MaximumLength(10_000);
+ }
+}
diff --git a/api/src/Feature.Notifications/ListRecipients/Endpoint.cs b/api/src/Feature.Notifications/ListRecipients/Endpoint.cs
index 3eafdb34f..cd9996f78 100644
--- a/api/src/Feature.Notifications/ListRecipients/Endpoint.cs
+++ b/api/src/Feature.Notifications/ListRecipients/Endpoint.cs
@@ -74,9 +74,11 @@ NULL AS "QuickReportFollowUpStatus"
FROM
"Attachments" A
WHERE
- A."FormId" = FS."FormId"
+ (
+ (A."FormId" = FS."FormId" AND FS."PollingStationId" = A."PollingStationId") -- backwards compatibility
+ OR A."SubmissionId" = FS."Id"
+ )
AND A."MonitoringObserverId" = FS."MonitoringObserverId"
- AND FS."PollingStationId" = A."PollingStationId"
AND A."IsDeleted" = FALSE
AND A."IsCompleted" = TRUE
) AS "MediaFilesCount",
@@ -86,9 +88,11 @@ NULL AS "QuickReportFollowUpStatus"
FROM
"Notes" N
WHERE
- N."FormId" = FS."FormId"
+ (
+ (N."FormId" = FS."FormId" AND FS."PollingStationId" = N."PollingStationId") -- backwards compatibility
+ OR N."SubmissionId" = FS."Id"
+ )
AND N."MonitoringObserverId" = FS."MonitoringObserverId"
- AND FS."PollingStationId" = N."PollingStationId"
) AS "NotesCount",
(
CASE
@@ -331,9 +335,11 @@ NULL AS "QuickReportFollowUpStatus"
FROM
"Attachments" A
WHERE
- A."FormId" = FS."FormId"
+ (
+ (A."FormId" = FS."FormId" AND FS."PollingStationId" = A."PollingStationId") -- backwards compatibility
+ OR A."SubmissionId" = FS."Id"
+ )
AND A."MonitoringObserverId" = FS."MonitoringObserverId"
- AND FS."PollingStationId" = A."PollingStationId"
AND A."IsDeleted" = FALSE
AND A."IsCompleted" = TRUE
) AS "MediaFilesCount",
@@ -343,9 +349,11 @@ NULL AS "QuickReportFollowUpStatus"
FROM
"Notes" N
WHERE
- N."FormId" = FS."FormId"
+ (
+ (N."FormId" = FS."FormId" AND FS."PollingStationId" = N."PollingStationId") -- backwards compatibility
+ OR N."SubmissionId" = FS."Id"
+ )
AND N."MonitoringObserverId" = FS."MonitoringObserverId"
- AND FS."PollingStationId" = N."PollingStationId"
) AS "NotesCount",
(
CASE
diff --git a/api/src/Feature.Notifications/Send/Endpoint.cs b/api/src/Feature.Notifications/Send/Endpoint.cs
index 4d5d1932f..8eb0c9757 100644
--- a/api/src/Feature.Notifications/Send/Endpoint.cs
+++ b/api/src/Feature.Notifications/Send/Endpoint.cs
@@ -80,9 +80,11 @@ NULL AS "QuickReportFollowUpStatus"
FROM
"Attachments" A
WHERE
- A."FormId" = FS."FormId"
+ (
+ (A."FormId" = FS."FormId" AND FS."PollingStationId" = A."PollingStationId") -- backwards compatibility
+ OR A."SubmissionId" = FS."Id"
+ )
AND A."MonitoringObserverId" = FS."MonitoringObserverId"
- AND FS."PollingStationId" = A."PollingStationId"
AND A."IsDeleted" = FALSE
AND A."IsCompleted" = TRUE
) AS "MediaFilesCount",
@@ -92,9 +94,11 @@ NULL AS "QuickReportFollowUpStatus"
FROM
"Notes" N
WHERE
- N."FormId" = FS."FormId"
+ (
+ (N."FormId" = FS."FormId" AND FS."PollingStationId" = N."PollingStationId") -- backwards compatibility
+ OR N."SubmissionId" = FS."Id"
+ )
AND N."MonitoringObserverId" = FS."MonitoringObserverId"
- AND FS."PollingStationId" = N."PollingStationId"
) AS "NotesCount",
(
CASE
diff --git a/api/src/Feature.PollingStation.Visits/Delete/Endpoint.cs b/api/src/Feature.PollingStation.Visits/Delete/Endpoint.cs
index 7ff5d4919..a008c9816 100644
--- a/api/src/Feature.PollingStation.Visits/Delete/Endpoint.cs
+++ b/api/src/Feature.PollingStation.Visits/Delete/Endpoint.cs
@@ -66,10 +66,11 @@ DELETE FROM "FormSubmissions"
FROM
"Attachments" A
INNER JOIN "MonitoringObservers" MO ON A."MonitoringObserverId" = MO."Id"
+ LEFT JOIN "FormSubmissions" FS ON A."SubmissionId" = FS."Id"
WHERE
- A."ElectionRoundId" = @electionRoundId
+ MO."ElectionRoundId" = @electionRoundId
AND MO."ObserverId" = @observerId
- AND A."PollingStationId" = @pollingStationId
+ AND (A."PollingStationId" = @pollingStationId or FS."PollingStationId" = @pollingStationId)
)
""";
@@ -82,10 +83,11 @@ DELETE FROM "Notes"
FROM
"Notes" N
INNER JOIN "MonitoringObservers" MO ON N."MonitoringObserverId" = MO."Id"
+ LEFT JOIN "FormSubmissions" FS ON N."SubmissionId" = FS."Id"
WHERE
- N."ElectionRoundId" = @electionRoundId
+ MO."ElectionRoundId" = @electionRoundId
AND MO."ObserverId" = @observerId
- AND N."PollingStationId" = @pollingStationId
+ AND (N."PollingStationId" = @pollingStationId or FS."PollingStationId" = @pollingStationId)
)
""";
diff --git a/api/src/Feature.Statistics/GetElectionsOverview/Endpoint.cs b/api/src/Feature.Statistics/GetElectionsOverview/Endpoint.cs
index c197fd952..1259592d8 100644
--- a/api/src/Feature.Statistics/GetElectionsOverview/Endpoint.cs
+++ b/api/src/Feature.Statistics/GetElectionsOverview/Endpoint.cs
@@ -92,7 +92,7 @@ DISTINCT CONCAT(
INNER JOIN "Forms" F ON F."Id" = FS."FormId"
WHERE
FS."ElectionRoundId" = ANY (@electionRoundIds)
- AND F."Status" = 'Published'
+ AND F."Status" <> 'Drafted'
AND FS."NumberOfQuestionsAnswered" > 0
UNION
SELECT
@@ -117,7 +117,7 @@ AND FS."NumberOfQuestionsAnswered" > 0
WHERE
FS."ElectionRoundId" = ANY (@electionRoundIds)
AND FS."NumberOfQuestionsAnswered" > 0
- AND F."Status" = 'Published'
+ AND F."Status" <> 'Drafted'
) + (
SELECT
COUNT(1)
@@ -140,7 +140,7 @@ AND FS."NumberOfQuestionsAnswered" > 0
WHERE
FS."ElectionRoundId" = ANY (@electionRoundIds)
AND FS."NumberOfQuestionsAnswered" > 0
- AND F."Status" = 'Published'
+ AND F."Status" <> 'Drafted'
) + (
SELECT
SUM("NumberOfQuestionsAnswered")
@@ -159,7 +159,7 @@ AND FS."NumberOfQuestionsAnswered" > 0
WHERE
FS."ElectionRoundId" = ANY (@electionRoundIds)
AND "NumberOfQuestionsAnswered" > 0
- AND F."Status" = 'Published';
+ AND F."Status" <> 'Drafted';
------------------------------
-- minutes monitoring
diff --git a/api/src/Feature.Statistics/GetObserverStatistics/Endpoint.cs b/api/src/Feature.Statistics/GetObserverStatistics/Endpoint.cs
index 067035b12..d2c7499d3 100644
--- a/api/src/Feature.Statistics/GetObserverStatistics/Endpoint.cs
+++ b/api/src/Feature.Statistics/GetObserverStatistics/Endpoint.cs
@@ -74,9 +74,9 @@ UNION ALL
COUNT(*) AS "NumberOfNotes"
FROM
"Notes" N
- INNER JOIN "MonitoringObservers" MO ON N."MonitoringObserverId" = MO."Id"
+ INNER JOIN "MonitoringObservers" MO ON N."MonitoringObserverId" = MO."Id"
WHERE
- N."ElectionRoundId" = @electionRoundId
+ MO."ElectionRoundId" = @electionRoundId
AND MO."ObserverId" = @observerId
),
"AttachmentsStats" AS (
@@ -88,9 +88,9 @@ UNION ALL
COUNT(*) AS "NumberOfAttachments"
FROM
"Attachments" A
- INNER JOIN "MonitoringObservers" MO ON A."MonitoringObserverId" = MO."Id"
+ INNER JOIN "MonitoringObservers" MO ON A."MonitoringObserverId" = MO."Id"
WHERE
- A."ElectionRoundId" = @electionRoundId
+ MO."ElectionRoundId" = @electionRoundId
AND MO."ObserverId" = @observerId
AND A."IsDeleted" = FALSE
UNION ALL
diff --git a/api/src/Vote.Monitor.Api/appsettings.Development.json b/api/src/Vote.Monitor.Api/appsettings.Development.json
index 3ca534861..bcc680ea9 100644
--- a/api/src/Vote.Monitor.Api/appsettings.Development.json
+++ b/api/src/Vote.Monitor.Api/appsettings.Development.json
@@ -35,9 +35,10 @@
"MiniIO": {
"BucketName": "user-uploads",
"PresignedUrlValidityInSeconds": 432000,
- "AccessKey": "minioadmin",
- "SecretKey": "minioadmin",
- "EndpointUrl": "http://localhost:9000"
+ "Region": "us-east-1",
+ "AccessKey": "",
+ "SecretKey": "",
+ "EndpointUrl": ""
}
},
"ApiConfiguration": {
diff --git a/api/src/Vote.Monitor.Core/Services/FileStorage/MiniIO/Installer.cs b/api/src/Vote.Monitor.Core/Services/FileStorage/MiniIO/Installer.cs
index ec10d4b82..0258346ed 100644
--- a/api/src/Vote.Monitor.Core/Services/FileStorage/MiniIO/Installer.cs
+++ b/api/src/Vote.Monitor.Core/Services/FileStorage/MiniIO/Installer.cs
@@ -13,12 +13,16 @@ internal static IServiceCollection AddMiniIOFileStorage(this IServiceCollection
string accessKey = configuration.GetSection("AccessKey").Value!;
string secretKey = configuration.GetSection("SecretKey").Value!;
string endpointUrl = configuration.GetSection("EndpointUrl").Value!;
+ string awsRegion = configuration.GetSection("Region").Value!;
+ var region = Amazon.RegionEndpoint.GetBySystemName(awsRegion);
services.AddSingleton(new AmazonS3Client(accessKey, secretKey, new AmazonS3Config()
{
+ AuthenticationRegion = "eu-central-1",
+ RegionEndpoint = region,
ServiceURL = endpointUrl, // Set MinIO URL
- ForcePathStyle = true // Required for MinIO
+ ForcePathStyle = true // Required for MinIO,
}));
services.AddSingleton();
diff --git a/api/src/Vote.Monitor.Core/Services/FileStorage/MiniIO/MiniIOOptions.cs b/api/src/Vote.Monitor.Core/Services/FileStorage/MiniIO/MiniIOOptions.cs
index 9dbc01888..f1718f458 100644
--- a/api/src/Vote.Monitor.Core/Services/FileStorage/MiniIO/MiniIOOptions.cs
+++ b/api/src/Vote.Monitor.Core/Services/FileStorage/MiniIO/MiniIOOptions.cs
@@ -4,5 +4,7 @@ public class MiniIOOptions
{
public const string SectionName = "MiniIO";
public string BucketName { get; set; } = "user-uploads";
+ public string Region { get; set; }
+ public string EndpointUrl { get; set; }
public int PresignedUrlValidityInSeconds { get; set; }
}
diff --git a/api/src/Vote.Monitor.Domain/Entities/AttachmentAggregate/Attachment.cs b/api/src/Vote.Monitor.Domain/Entities/AttachmentAggregate/Attachment.cs
index 2beb41b05..a2a853c40 100644
--- a/api/src/Vote.Monitor.Domain/Entities/AttachmentAggregate/Attachment.cs
+++ b/api/src/Vote.Monitor.Domain/Entities/AttachmentAggregate/Attachment.cs
@@ -6,15 +6,18 @@ namespace Vote.Monitor.Domain.Entities.AttachmentAggregate;
public class Attachment : IAggregateRoot
{
public Guid Id { get; private set; }
+
+ [Obsolete("Will be removed in future version")]
public Guid ElectionRoundId { get; private set; }
- public ElectionRound ElectionRound { get; private set; }
+
+ [Obsolete("Will be removed in future version")]
public Guid PollingStationId { get; private set; }
- public PollingStation PollingStation { get; private set; }
public Guid MonitoringObserverId { get; private set; }
public MonitoringObserver MonitoringObserver { get; private set; }
+ [Obsolete("Will be removed in future version")]
public Guid FormId { get; private set; }
- public Form Form { get; private set; }
+ public Guid SubmissionId { get; private set; }
public Guid QuestionId { get; private set; }
public string FileName { get; private set; }
public string UploadedFileName { get; private set; }
@@ -25,6 +28,7 @@ public class Attachment : IAggregateRoot
public bool IsCompleted { get; private set; }
public DateTime LastUpdatedAt { get; private set; }
+ [Obsolete("Will be removed in future version")]
private Attachment(Guid id,
Guid electionRoundId,
Guid pollingStationId,
@@ -59,6 +63,36 @@ private Attachment(Guid id,
LastUpdatedAt = lastUpdatedAt;
}
+ private Attachment(Guid id,
+ Guid submissionId,
+ Guid monitoringObserverId,
+ Guid questionId,
+ string fileName,
+ string filePath,
+ string mimeType,
+ bool? isCompleted,
+ DateTime lastUpdatedAt)
+ {
+ Id = id;
+ MonitoringObserverId = monitoringObserverId;
+ SubmissionId = submissionId;
+ QuestionId = questionId;
+ FileName = fileName;
+ FilePath = filePath;
+ MimeType = mimeType;
+ IsDeleted = false;
+
+ if (isCompleted.HasValue)
+ {
+ IsCompleted = isCompleted.Value;
+ }
+
+ var extension = FileName.Split('.').Last();
+ var uploadedFileName = $"{Id}.{extension}";
+ UploadedFileName = uploadedFileName;
+ LastUpdatedAt = lastUpdatedAt;
+ }
+
public void Delete()
{
IsDeleted = true;
@@ -68,7 +102,8 @@ public void Complete()
{
IsCompleted = true;
}
-
+
+ [Obsolete("Will be removed in future version")]
public static Attachment Create(Guid id,
Guid electionRoundId,
Guid pollingStationId,
@@ -80,7 +115,17 @@ public static Attachment Create(Guid id,
string mimeType,
DateTime lastUpdatedAt) => new(id, electionRoundId, pollingStationId, monitoringObserverId, formId, questionId,
fileName, filePath, mimeType, false, lastUpdatedAt);
-
+
+ public static Attachment CreateV2(Guid id,
+ Guid submissionId,
+ Guid monitoringObserverId,
+ Guid questionId,
+ string fileName,
+ string filePath,
+ string mimeType,
+ DateTime lastUpdatedAt) => new(id, submissionId, monitoringObserverId, questionId,
+ fileName, filePath, mimeType, false, lastUpdatedAt);
+
#pragma warning disable CS8618 // Required by Entity Framework
internal Attachment()
diff --git a/api/src/Vote.Monitor.Domain/Entities/ElectionRoundAggregate/ElectionRound.cs b/api/src/Vote.Monitor.Domain/Entities/ElectionRoundAggregate/ElectionRound.cs
index 118004433..7bf007189 100644
--- a/api/src/Vote.Monitor.Domain/Entities/ElectionRoundAggregate/ElectionRound.cs
+++ b/api/src/Vote.Monitor.Domain/Entities/ElectionRoundAggregate/ElectionRound.cs
@@ -92,7 +92,7 @@ public virtual void Unarchive()
Status = ElectionRoundStatus.NotStarted;
}
- public virtual MonitoringNgo AddMonitoringNgo(Ngo ngo)
+ public virtual MonitoringNgo AddMonitoringNgo(Ngo ngo, bool allowMultipleFormSubmission = false)
{
var monitoringNgo = _monitoringNgos.FirstOrDefault(x => x.NgoId == ngo.Id);
@@ -101,7 +101,7 @@ public virtual MonitoringNgo AddMonitoringNgo(Ngo ngo)
return monitoringNgo;
}
- monitoringNgo = new MonitoringNgo(this, ngo);
+ monitoringNgo = new MonitoringNgo(this, ngo, allowMultipleFormSubmission);
_monitoringNgos.Add(monitoringNgo);
return monitoringNgo;
diff --git a/api/src/Vote.Monitor.Domain/Entities/FormAggregate/Form.cs b/api/src/Vote.Monitor.Domain/Entities/FormAggregate/Form.cs
index bb5e116a6..8dd5d2acb 100644
--- a/api/src/Vote.Monitor.Domain/Entities/FormAggregate/Form.cs
+++ b/api/src/Vote.Monitor.Domain/Entities/FormAggregate/Form.cs
@@ -138,10 +138,12 @@ public Form Duplicate() =>
new(ElectionRoundId, MonitoringNgoId, FormType, Code, Name, Description, DefaultLanguage, Languages, Icon,
Questions);
+ [Obsolete("Will be removed in future version")]
public FormSubmission CreateFormSubmission(PollingStation pollingStation,
MonitoringObserver monitoringObserver,
List? answers,
bool? isCompleted,
+ DateTime createdAt,
DateTime lastUpdatedAt)
{
answers ??= [];
@@ -163,6 +165,40 @@ public FormSubmission CreateFormSubmission(PollingStation pollingStation,
numberOfQuestionAnswered,
numberOfFlaggedAnswers,
isCompleted,
+ createdAt,
+ lastUpdatedAt);
+ }
+
+ public FormSubmission CreateFormSubmissionV2(Guid submissionId,
+ PollingStation pollingStation,
+ MonitoringObserver monitoringObserver,
+ List? answers,
+ bool? isCompleted,
+ DateTime createdAt,
+ DateTime lastUpdatedAt)
+ {
+ answers ??= [];
+ var numberOfQuestionAnswered = AnswersHelpers.CountNumberOfQuestionsAnswered(Questions, answers);
+ var numberOfFlaggedAnswers = AnswersHelpers.CountNumberOfFlaggedAnswers(Questions, answers);
+
+ var validationResult = AnswersValidator.GetValidationResults(answers, Questions);
+
+ if (!validationResult.IsValid)
+ {
+ throw new ValidationException(validationResult.Errors);
+ }
+
+ return FormSubmission.CreateV2(
+ submissionId,
+ ElectionRound,
+ pollingStation,
+ monitoringObserver,
+ this,
+ answers,
+ numberOfQuestionAnswered,
+ numberOfFlaggedAnswers,
+ isCompleted,
+ createdAt,
lastUpdatedAt);
}
@@ -213,11 +249,11 @@ public IncidentReport CreateIncidentReport(
return IncidentReport.Create(incidentReportId, ElectionRoundId, monitoringObserver, locationType,
pollingStationId,
- locationDescription,
- formId: Id,
+ locationDescription,
+ formId: Id,
answers,
numberOfQuestionAnswered,
- numberOfFlaggedAnswers,
+ numberOfFlaggedAnswers,
isCompleted,
lastUpdatedAt);
}
diff --git a/api/src/Vote.Monitor.Domain/Entities/FormSubmissionAggregate/FormSubmission.cs b/api/src/Vote.Monitor.Domain/Entities/FormSubmissionAggregate/FormSubmission.cs
index 7fc2e09c7..0f2e5e9f5 100644
--- a/api/src/Vote.Monitor.Domain/Entities/FormSubmissionAggregate/FormSubmission.cs
+++ b/api/src/Vote.Monitor.Domain/Entities/FormSubmissionAggregate/FormSubmission.cs
@@ -19,10 +19,12 @@ public class FormSubmission : IAggregateRoot
public int NumberOfFlaggedAnswers { get; private set; }
public SubmissionFollowUpStatus FollowUpStatus { get; private set; }
public bool IsCompleted { get; private set; }
+ public DateTime CreatedAt { get; private set; }
public DateTime LastUpdatedAt { get; private set; }
public IReadOnlyList Answers { get; private set; } = new List().AsReadOnly();
private FormSubmission(
+ Guid submissionId,
ElectionRound electionRound,
PollingStation pollingStation,
MonitoringObserver monitoringObserver,
@@ -31,9 +33,10 @@ private FormSubmission(
int numberOfQuestionsAnswered,
int numberOfFlaggedAnswers,
bool? isCompleted,
+ DateTime createdAt,
DateTime lastUpdatedAt)
{
- Id = Guid.NewGuid();
+ Id = submissionId;
ElectionRound = electionRound;
ElectionRoundId = electionRound.Id;
PollingStation = pollingStation;
@@ -46,6 +49,7 @@ private FormSubmission(
NumberOfQuestionsAnswered = numberOfQuestionsAnswered;
NumberOfFlaggedAnswers = numberOfFlaggedAnswers;
FollowUpStatus = SubmissionFollowUpStatus.NotApplicable;
+ CreatedAt = createdAt;
LastUpdatedAt = lastUpdatedAt;
if (isCompleted.HasValue)
@@ -53,7 +57,7 @@ private FormSubmission(
IsCompleted = isCompleted.Value;
}
}
-
+
internal static FormSubmission Create(ElectionRound electionRound,
PollingStation pollingStation,
MonitoringObserver monitoringObserver,
@@ -62,8 +66,35 @@ internal static FormSubmission Create(ElectionRound electionRound,
int numberOfQuestionAnswered,
int numberOfFlaggedAnswers,
bool? isCompleted,
+ DateTime createdAt,
+ DateTime lastUpdatedAt) =>
+ new(Guid.NewGuid(),
+ electionRound,
+ pollingStation,
+ monitoringObserver,
+ form,
+ answers,
+ numberOfQuestionAnswered,
+ numberOfFlaggedAnswers,
+ isCompleted,
+ createdAt,
+ lastUpdatedAt);
+
+ internal static FormSubmission CreateV2(
+ Guid submissionId,
+ ElectionRound electionRound,
+ PollingStation pollingStation,
+ MonitoringObserver monitoringObserver,
+ Form form,
+ List answers,
+ int numberOfQuestionAnswered,
+ int numberOfFlaggedAnswers,
+ bool? isCompleted,
+ DateTime createdAt,
DateTime lastUpdatedAt) =>
- new(electionRound,
+ new(
+ submissionId,
+ electionRound,
pollingStation,
monitoringObserver,
form,
@@ -71,6 +102,7 @@ internal static FormSubmission Create(ElectionRound electionRound,
numberOfQuestionAnswered,
numberOfFlaggedAnswers,
isCompleted,
+ createdAt,
lastUpdatedAt);
internal void Update(IEnumerable? answers,
diff --git a/api/src/Vote.Monitor.Domain/Entities/MonitoringNgoAggregate/MonitoringNgo.cs b/api/src/Vote.Monitor.Domain/Entities/MonitoringNgoAggregate/MonitoringNgo.cs
index e6d1d8437..aadea2f78 100644
--- a/api/src/Vote.Monitor.Domain/Entities/MonitoringNgoAggregate/MonitoringNgo.cs
+++ b/api/src/Vote.Monitor.Domain/Entities/MonitoringNgoAggregate/MonitoringNgo.cs
@@ -13,12 +13,14 @@ public class MonitoringNgo : AuditableBaseEntity, IAggregateRoot
public Guid NgoId { get; private set; }
public Ngo Ngo { get; private set; }
public Guid FormsVersion { get; private set; }
+
+ public bool AllowMultipleFormSubmission { get; private set; }
public virtual List MonitoringObservers { get; internal set; } = [];
public MonitoringNgoStatus Status { get; private set; }
public virtual List Memberships { get; internal set; } = [];
- internal MonitoringNgo(ElectionRound electionRound, Ngo ngo)
+ internal MonitoringNgo(ElectionRound electionRound, Ngo ngo, bool allowMultipleFormSubmission = false)
{
Id = Guid.NewGuid();
ElectionRound = electionRound;
@@ -27,6 +29,7 @@ internal MonitoringNgo(ElectionRound electionRound, Ngo ngo)
NgoId = ngo.Id;
Status = MonitoringNgoStatus.Active;
FormsVersion = Guid.NewGuid();
+ AllowMultipleFormSubmission = allowMultipleFormSubmission;
}
public virtual MonitoringObserver? AddMonitoringObserver(Observer observer)
@@ -68,15 +71,25 @@ public void Suspend()
{
Status = MonitoringNgoStatus.Suspended;
}
+
public void UpdateFormVersion()
{
FormsVersion = Guid.NewGuid();
}
-
+
+ public void EnableMultipleFormSubmission()
+ {
+ AllowMultipleFormSubmission = true;
+ }
+
+ public void DisableMultipleFormSubmission()
+ {
+ AllowMultipleFormSubmission = true;
+ }
+
#pragma warning disable CS8618 // Required by Entity Framework
private MonitoringNgo()
{
-
}
#pragma warning restore CS8618
}
diff --git a/api/src/Vote.Monitor.Domain/Entities/NoteAggregate/Note.cs b/api/src/Vote.Monitor.Domain/Entities/NoteAggregate/Note.cs
index e59f43e82..a4ee00183 100644
--- a/api/src/Vote.Monitor.Domain/Entities/NoteAggregate/Note.cs
+++ b/api/src/Vote.Monitor.Domain/Entities/NoteAggregate/Note.cs
@@ -6,17 +6,23 @@ namespace Vote.Monitor.Domain.Entities.NoteAggregate;
public class Note : IAggregateRoot
{
public Guid Id { get; private set; }
+
+ [Obsolete("Will be removed in future version")]
public Guid ElectionRoundId { get; private set; }
+
+ [Obsolete("Will be removed in future version")]
public Guid PollingStationId { get; private set; }
- public Guid MonitoringObserverId { get; private set; }
+
+ [Obsolete("Will be removed in future version")]
public Guid FormId { get; private set; }
+
+ public Guid MonitoringObserverId { get; private set; }
+
+ public Guid SubmissionId { get; private set; }
public Guid QuestionId { get; private set; }
public string Text { get; private set; }
public DateTime LastUpdatedAt { get; private set; }
-
- public ElectionRound ElectionRound { get; private set; }
- public Form Form { get; private set; }
- public PollingStation PollingStation { get; private set; }
+
public MonitoringObserver MonitoringObserver { get; private set; }
public Note(Guid id,
@@ -38,6 +44,28 @@ public Note(Guid id,
LastUpdatedAt = lastUpdatedAt;
}
+ private Note(Guid id,
+ Guid submissionId,
+ Guid monitoringObserverId,
+ Guid questionId,
+ string text,
+ DateTime lastUpdatedAt)
+ {
+ Id = id;
+ SubmissionId = submissionId;
+ MonitoringObserverId = monitoringObserverId;
+ QuestionId = questionId;
+ Text = text;
+ LastUpdatedAt = lastUpdatedAt;
+ }
+
+ public static Note Create(Guid id,
+ Guid monitoringObserverId,
+ Guid submissionId,
+ Guid questionId,
+ string text,
+ DateTime lastUpdatedAt) => new(id, submissionId, monitoringObserverId, questionId, text, lastUpdatedAt);
+
public void UpdateText(string text, DateTime lastUpdatedAt)
{
Text = text;
diff --git a/api/src/Vote.Monitor.Domain/EntitiesConfiguration/AttachmentConfiguration.cs b/api/src/Vote.Monitor.Domain/EntitiesConfiguration/AttachmentConfiguration.cs
index aa5de1904..b9785a607 100644
--- a/api/src/Vote.Monitor.Domain/EntitiesConfiguration/AttachmentConfiguration.cs
+++ b/api/src/Vote.Monitor.Domain/EntitiesConfiguration/AttachmentConfiguration.cs
@@ -12,10 +12,8 @@ public void Configure(EntityTypeBuilder builder)
builder.HasKey(x => x.Id);
builder.Property(x => x.Id).IsRequired();
- builder.HasIndex(x => x.ElectionRoundId);
builder.HasIndex(x => x.MonitoringObserverId);
- builder.HasIndex(x => x.PollingStationId);
- builder.HasIndex(x => x.FormId);
+ builder.Property(x => x.SubmissionId).IsRequired();
builder.Property(x => x.QuestionId).IsRequired();
builder.Property(x => x.FileName)
@@ -38,15 +36,9 @@ public void Configure(EntityTypeBuilder builder)
builder.Property(x => x.LastUpdatedAt).IsRequired();
-
- builder.HasOne(x => x.ElectionRound)
- .WithMany()
- .HasForeignKey(x => x.ElectionRoundId);
-
- builder.HasOne(x => x.PollingStation)
- .WithMany()
- .HasForeignKey(x => x.PollingStationId);
-
+ builder.Property(x => x.FormId);
+ builder.Property(x => x.ElectionRoundId);
+ builder.Property(x => x.PollingStationId);
builder.HasOne(x => x.MonitoringObserver)
.WithMany()
.HasForeignKey(x => x.MonitoringObserverId);
diff --git a/api/src/Vote.Monitor.Domain/EntitiesConfiguration/FormSubmissionConfiguration.cs b/api/src/Vote.Monitor.Domain/EntitiesConfiguration/FormSubmissionConfiguration.cs
index 35f75cbdd..6ba18e260 100644
--- a/api/src/Vote.Monitor.Domain/EntitiesConfiguration/FormSubmissionConfiguration.cs
+++ b/api/src/Vote.Monitor.Domain/EntitiesConfiguration/FormSubmissionConfiguration.cs
@@ -19,7 +19,7 @@ public void Configure(EntityTypeBuilder builder)
x.PollingStationId,
x.MonitoringObserverId,
x.FormId
- }).IsUnique();
+ });
builder.Property(x => x.NumberOfFlaggedAnswers).IsRequired();
builder.Property(x => x.NumberOfQuestionsAnswered).IsRequired();
@@ -27,6 +27,7 @@ public void Configure(EntityTypeBuilder builder)
.IsRequired()
.HasDefaultValue(SubmissionFollowUpStatus.NotApplicable);
builder.Property(x => x.LastUpdatedAt).IsRequired();
+ builder.Property(x => x.CreatedAt).IsRequired();
builder.HasOne(x => x.ElectionRound)
.WithMany()
diff --git a/api/src/Vote.Monitor.Domain/EntitiesConfiguration/MonitoringNgoConfiguration.cs b/api/src/Vote.Monitor.Domain/EntitiesConfiguration/MonitoringNgoConfiguration.cs
index 17d61c236..7a8920449 100644
--- a/api/src/Vote.Monitor.Domain/EntitiesConfiguration/MonitoringNgoConfiguration.cs
+++ b/api/src/Vote.Monitor.Domain/EntitiesConfiguration/MonitoringNgoConfiguration.cs
@@ -15,6 +15,9 @@ public void Configure(EntityTypeBuilder builder)
builder.HasIndex(x => x.ElectionRoundId);
builder.HasIndex(x => x.NgoId);
+ builder.Property(x => x.AllowMultipleFormSubmission)
+ .IsRequired()
+ .HasDefaultValue(false);
builder
.HasMany(e => e.MonitoringObservers)
diff --git a/api/src/Vote.Monitor.Domain/EntitiesConfiguration/NoteConfiguration.cs b/api/src/Vote.Monitor.Domain/EntitiesConfiguration/NoteConfiguration.cs
index d4a6c12cb..3d49dacb8 100644
--- a/api/src/Vote.Monitor.Domain/EntitiesConfiguration/NoteConfiguration.cs
+++ b/api/src/Vote.Monitor.Domain/EntitiesConfiguration/NoteConfiguration.cs
@@ -12,9 +12,9 @@ public void Configure(EntityTypeBuilder builder)
builder.HasKey(x => x.Id);
builder.Property(x => x.Id).IsRequired();
- builder.HasIndex(x => x.ElectionRoundId);
builder.HasIndex(x => x.MonitoringObserverId);
- builder.HasIndex(x => x.FormId);
+ builder.Property(x => x.FormId);
+ builder.Property(x => x.SubmissionId).IsRequired();
builder.Property(x => x.QuestionId).IsRequired();
builder.Property(x => x.Text)
@@ -22,17 +22,10 @@ public void Configure(EntityTypeBuilder builder)
.IsRequired();
builder.Property(x => x.LastUpdatedAt).IsRequired();
-
- builder.HasOne(x => x.ElectionRound)
- .WithMany()
- .HasForeignKey(x => x.ElectionRoundId);
-
+
builder.HasOne(x => x.MonitoringObserver)
.WithMany()
.HasForeignKey(x => x.MonitoringObserverId);
- builder.HasOne(x => x.Form)
- .WithMany()
- .HasForeignKey(x => x.FormId);
}
}
diff --git a/api/src/Vote.Monitor.Domain/Migrations/20250814122053_AddAllowMultipleFormSubmission.Designer.cs b/api/src/Vote.Monitor.Domain/Migrations/20250814122053_AddAllowMultipleFormSubmission.Designer.cs
new file mode 100644
index 000000000..4c86a01c6
--- /dev/null
+++ b/api/src/Vote.Monitor.Domain/Migrations/20250814122053_AddAllowMultipleFormSubmission.Designer.cs
@@ -0,0 +1,7036 @@
+//
+using System;
+using System.Collections.Generic;
+using System.Text.Json;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using Vote.Monitor.Domain;
+
+#nullable disable
+
+namespace Vote.Monitor.Domain.Migrations
+{
+ [DbContext(typeof(VoteMonitorContext))]
+ [Migration("20250814122053_AddAllowMultipleFormSubmission")]
+ partial class AddAllowMultipleFormSubmission
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "8.0.10")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "hstore");
+ NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "uuid-ossp");
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .HasColumnType("text");
+
+ b.Property("Name")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("NormalizedName")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NormalizedName")
+ .IsUnique()
+ .HasDatabaseName("RoleNameIndex");
+
+ b.ToTable("AspNetRoles", (string)null);
+
+ b.HasData(
+ new
+ {
+ Id = new Guid("265e94b0-50fe-4546-b21c-83cb7e94aeff"),
+ Name = "PlatformAdmin",
+ NormalizedName = "PLATFORMADMIN"
+ },
+ new
+ {
+ Id = new Guid("3239f803-dda8-408b-93ad-0ed973a04e45"),
+ Name = "NgoAdmin",
+ NormalizedName = "NGOADMIN"
+ },
+ new
+ {
+ Id = new Guid("d1cbef39-62e0-4120-a42b-b01b029dc6ad"),
+ Name = "Observer",
+ NormalizedName = "OBSERVER"
+ });
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ClaimType")
+ .HasColumnType("text");
+
+ b.Property("ClaimValue")
+ .HasColumnType("text");
+
+ b.Property("RoleId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("RoleId");
+
+ b.ToTable("AspNetRoleClaims", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ClaimType")
+ .HasColumnType("text");
+
+ b.Property("ClaimValue")
+ .HasColumnType("text");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("AspNetUserClaims", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b =>
+ {
+ b.Property("LoginProvider")
+ .HasColumnType("text");
+
+ b.Property("ProviderKey")
+ .HasColumnType("text");
+
+ b.Property("ProviderDisplayName")
+ .HasColumnType("text");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("LoginProvider", "ProviderKey");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("AspNetUserLogins", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.Property("RoleId")
+ .HasColumnType("uuid");
+
+ b.HasKey("UserId", "RoleId");
+
+ b.HasIndex("RoleId");
+
+ b.ToTable("AspNetUserRoles", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.Property("LoginProvider")
+ .HasColumnType("text");
+
+ b.Property("Name")
+ .HasColumnType("text");
+
+ b.Property("Value")
+ .HasColumnType("text");
+
+ b.HasKey("UserId", "LoginProvider", "Name");
+
+ b.ToTable("AspNetUserTokens", (string)null);
+ });
+
+ modelBuilder.Entity("Vote.Monitor.Domain.Entities.ApplicationUserAggregate.ApplicationUser", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AccessFailedCount")
+ .HasColumnType("integer");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .HasColumnType("text");
+
+ b.Property("DisplayName")
+ .IsRequired()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("text")
+ .HasComputedColumnSql("\"FirstName\" || ' ' || \"LastName\"", true);
+
+ b.Property("Email")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("EmailConfirmed")
+ .HasColumnType("boolean");
+
+ b.Property("FirstName")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("InvitationToken")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("LastName")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("LockoutEnabled")
+ .HasColumnType("boolean");
+
+ b.Property("LockoutEnd")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("NormalizedEmail")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("NormalizedUserName")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("PasswordHash")
+ .HasColumnType("text");
+
+ b.Property("PhoneNumber")
+ .HasColumnType("text");
+
+ b.Property("PhoneNumberConfirmed")
+ .HasColumnType("boolean");
+
+ b.Property("RefreshToken")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("RefreshTokenExpiryTime")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Role")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("SecurityStamp")
+ .HasColumnType("text");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("TwoFactorEnabled")
+ .HasColumnType("boolean");
+
+ b.Property("UserName")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NormalizedEmail")
+ .HasDatabaseName("EmailIndex");
+
+ b.HasIndex("NormalizedUserName")
+ .IsUnique()
+ .HasDatabaseName("UserNameIndex");
+
+ b.ToTable("AspNetUsers", (string)null);
+ });
+
+ modelBuilder.Entity("Vote.Monitor.Domain.Entities.AttachmentAggregate.Attachment", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("ElectionRoundId")
+ .HasColumnType("uuid");
+
+ b.Property("FileName")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("FilePath")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("FormId")
+ .HasColumnType("uuid");
+
+ b.Property("IsCompleted")
+ .HasColumnType("boolean");
+
+ b.Property("IsDeleted")
+ .HasColumnType("boolean");
+
+ b.Property("LastUpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("MimeType")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("MonitoringObserverId")
+ .HasColumnType("uuid");
+
+ b.Property("PollingStationId")
+ .HasColumnType("uuid");
+
+ b.Property("QuestionId")
+ .HasColumnType("uuid");
+
+ b.Property("UploadedFileName")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ElectionRoundId");
+
+ b.HasIndex("FormId");
+
+ b.HasIndex("MonitoringObserverId");
+
+ b.HasIndex("PollingStationId");
+
+ b.ToTable("Attachments", (string)null);
+ });
+
+ modelBuilder.Entity("Vote.Monitor.Domain.Entities.Auditing.Trail", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AffectedColumns")
+ .HasColumnType("text");
+
+ b.Property("CreatedOn")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("NewValues")
+ .HasColumnType("text");
+
+ b.Property("OldValues")
+ .HasColumnType("text");
+
+ b.Property("PrimaryKey")
+ .HasColumnType("text");
+
+ b.Property("TableName")
+ .HasColumnType("text");
+
+ b.Property("Timestamp")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Type")
+ .HasColumnType("text");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.ToTable("AuditTrails");
+ });
+
+ modelBuilder.Entity("Vote.Monitor.Domain.Entities.CitizenGuideAggregate.CitizenGuide", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedBy")
+ .HasColumnType("uuid");
+
+ b.Property("CreatedOn")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ElectionRoundId")
+ .HasColumnType("uuid");
+
+ b.Property("FileName")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("FilePath")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("GuideType")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("IsDeleted")
+ .HasColumnType("boolean");
+
+ b.Property("LastModifiedBy")
+ .HasColumnType("uuid");
+
+ b.Property("LastModifiedOn")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("MimeType")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("Text")
+ .HasColumnType("text");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("UploadedFileName")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("WebsiteUrl")
+ .HasMaxLength(2048)
+ .HasColumnType("character varying(2048)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ElectionRoundId");
+
+ b.ToTable("CitizenGuides");
+ });
+
+ modelBuilder.Entity("Vote.Monitor.Domain.Entities.CitizenNotificationAggregate.CitizenNotification", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Body")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("CreatedBy")
+ .HasColumnType("uuid");
+
+ b.Property("CreatedOn")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ElectionRoundId")
+ .HasColumnType("uuid");
+
+ b.Property("LastModifiedBy")
+ .HasColumnType("uuid");
+
+ b.Property("LastModifiedOn")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("SenderId")
+ .HasColumnType("uuid");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ElectionRoundId");
+
+ b.HasIndex("SenderId");
+
+ b.ToTable("CitizenNotifications");
+ });
+
+ modelBuilder.Entity("Vote.Monitor.Domain.Entities.CitizenReportAggregate.CitizenReport", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Answers")
+ .IsRequired()
+ .HasColumnType("jsonb");
+
+ b.Property("CreatedBy")
+ .HasColumnType("uuid");
+
+ b.Property("CreatedOn")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ElectionRoundId")
+ .HasColumnType("uuid");
+
+ b.Property("FollowUpStatus")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasColumnType("text")
+ .HasDefaultValue("NotApplicable");
+
+ b.Property("FormId")
+ .HasColumnType("uuid");
+
+ b.Property("LastModifiedBy")
+ .HasColumnType("uuid");
+
+ b.Property("LastModifiedOn")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("LocationId")
+ .HasColumnType("uuid");
+
+ b.Property("NumberOfFlaggedAnswers")
+ .HasColumnType("integer");
+
+ b.Property("NumberOfQuestionsAnswered")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ElectionRoundId");
+
+ b.HasIndex("FormId");
+
+ b.HasIndex("LocationId");
+
+ b.ToTable("CitizenReports");
+ });
+
+ modelBuilder.Entity("Vote.Monitor.Domain.Entities.CitizenReportAttachmentAggregate.CitizenReportAttachment", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CitizenReportId")
+ .HasColumnType("uuid");
+
+ b.Property("CreatedBy")
+ .HasColumnType("uuid");
+
+ b.Property("CreatedOn")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ElectionRoundId")
+ .HasColumnType("uuid");
+
+ b.Property("FileName")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("FilePath")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("FormId")
+ .HasColumnType("uuid");
+
+ b.Property("IsCompleted")
+ .HasColumnType("boolean");
+
+ b.Property("IsDeleted")
+ .HasColumnType("boolean");
+
+ b.Property("LastModifiedBy")
+ .HasColumnType("uuid");
+
+ b.Property("LastModifiedOn")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("MimeType")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("QuestionId")
+ .HasColumnType("uuid");
+
+ b.Property("UploadedFileName")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CitizenReportId");
+
+ b.HasIndex("ElectionRoundId");
+
+ b.HasIndex("FormId");
+
+ b.ToTable("CitizenReportAttachments");
+ });
+
+ modelBuilder.Entity("Vote.Monitor.Domain.Entities.CitizenReportNoteAggregate.CitizenReportNote", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CitizenReportId")
+ .HasColumnType("uuid");
+
+ b.Property("CreatedBy")
+ .HasColumnType("uuid");
+
+ b.Property("CreatedOn")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ElectionRoundId")
+ .HasColumnType("uuid");
+
+ b.Property("FormId")
+ .HasColumnType("uuid");
+
+ b.Property("LastModifiedBy")
+ .HasColumnType("uuid");
+
+ b.Property("LastModifiedOn")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("QuestionId")
+ .HasColumnType("uuid");
+
+ b.Property("Text")
+ .IsRequired()
+ .HasMaxLength(10000)
+ .HasColumnType("character varying(10000)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CitizenReportId");
+
+ b.HasIndex("ElectionRoundId");
+
+ b.HasIndex("FormId");
+
+ b.ToTable("CitizenReportNotes");
+ });
+
+ modelBuilder.Entity("Vote.Monitor.Domain.Entities.CoalitionAggregate.Coalition", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedBy")
+ .HasColumnType("uuid");
+
+ b.Property("CreatedOn")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ElectionRoundId")
+ .HasColumnType("uuid");
+
+ b.Property("LastModifiedBy")
+ .HasColumnType("uuid");
+
+ b.Property("LastModifiedOn")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("LeaderId")
+ .HasColumnType("uuid");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ElectionRoundId");
+
+ b.HasIndex("LeaderId");
+
+ b.ToTable("Coalitions");
+ });
+
+ modelBuilder.Entity("Vote.Monitor.Domain.Entities.CoalitionAggregate.CoalitionFormAccess", b =>
+ {
+ b.Property("CoalitionId")
+ .HasColumnType("uuid");
+
+ b.Property("MonitoringNgoId")
+ .HasColumnType("uuid");
+
+ b.Property("FormId")
+ .HasColumnType("uuid");
+
+ b.HasKey("CoalitionId", "MonitoringNgoId", "FormId");
+
+ b.HasIndex("FormId");
+
+ b.HasIndex("MonitoringNgoId");
+
+ b.ToTable("CoalitionFormAccess");
+ });
+
+ modelBuilder.Entity("Vote.Monitor.Domain.Entities.CoalitionAggregate.CoalitionGuideAccess", b =>
+ {
+ b.Property("CoalitionId")
+ .HasColumnType("uuid");
+
+ b.Property