Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
using Dapper;
using Module.Answers.Aggregators;
using Vote.Monitor.Core.RulesEngine;
using Vote.Monitor.Core.Services.FileStorage.Contracts;
using Vote.Monitor.Domain.ConnectionFactory;
using Vote.Monitor.Domain.Queries;

namespace Feature.CitizenReports.GetQuestionAggregatedByLevel;

public class Endpoint(
IAuthorizationService authorizationService,
IFileStorageService fileStorageService,
INpgsqlConnectionFactory dbConnectionFactory)
: Endpoint<Request, Results<Ok<Response>, NotFound>>
{
public override void Configure()
{
Post("/api/election-rounds/{electionRoundId}/citizen-reports/forms/{formId}/questions/{questionId}:aggregatedByLevel");
DontAutoTag();
Options(x => x.WithTags("citizen-reports"));
Policies(PolicyNames.NgoAdminOrStaff);
Summary(s =>
{
s.Summary = "Gets the aggregated answers to a citizen report question, broken down by the locations of a level (Level1..Level5)";
s.Description = "Returns 404 when the form does not belong to the ngo or does not contain the question.";
});
}

public override async Task<Results<Ok<Response>, NotFound>> ExecuteAsync(Request req, CancellationToken ct)
{
var authorizationResult =
await authorizationService.AuthorizeAsync(User,
new CitizenReportingNgoAdminOrStaffRequirement(req.ElectionRoundId));
if (!authorizationResult.Succeeded)
{
return TypedResults.NotFound();
}

var filter = new FilterSqlCompiler(V2ReportFilterFields.CitizenReports, parameterPrefixer: FilterSqlCompiler.FunctionParameter)
.Build(req.Filter);

const string sql = """
SELECT * FROM "GetCitizenReportsQuestionAggregateByLevel"(@electionRoundId, @ngoId, @formId, @questionId, @level, @filter, @filterParameters::jsonb)
""";

QuestionAggregateByLevel? aggregate;
using (var dbConnection = await dbConnectionFactory.GetOpenConnectionAsync(ct))
{
aggregate = await dbConnection.QuerySingleOrDefaultAsync<QuestionAggregateByLevel>(new CommandDefinition(sql, new
{
electionRoundId = req.ElectionRoundId,
ngoId = req.NgoId,
formId = req.FormId,
questionId = req.QuestionId,
level = req.Level.Depth,
filter = filter.Sql,
filterParameters = filter.ToJsonParameters()
}, cancellationToken: ct));
}

if (aggregate is null)
{
return TypedResults.NotFound();
}

aggregate.Attachments = await fileStorageService.PresignAsync(aggregate.Attachments);

return TypedResults.Ok(new Response { QuestionAggregate = aggregate });
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using Vote.Monitor.Core.Models;
using Vote.Monitor.Core.Security;

namespace Feature.CitizenReports.GetQuestionAggregatedByLevel;

public class Request : BaseFilterConditionsRequest
{
public Guid ElectionRoundId { get; set; }

[FromClaim(ApplicationClaimTypes.NgoId)]
public Guid NgoId { get; set; }

public Guid FormId { get; set; }

public Guid QuestionId { get; set; }

/// <summary>
/// The level to break the submissions down by.
/// </summary>
public LocationLevel Level { get; set; } = null!;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using Module.Answers.Aggregators;

namespace Feature.CitizenReports.GetQuestionAggregatedByLevel;

public class Response
{
public QuestionAggregateByLevel QuestionAggregate { get; set; } = null!;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
namespace Feature.CitizenReports.GetQuestionAggregatedByLevel;

public class Validator : Validator<Request>
{
public Validator()
{
RuleFor(x => x.ElectionRoundId).NotEmpty();
RuleFor(x => x.NgoId).NotEmpty();
RuleFor(x => x.FormId).NotEmpty();
RuleFor(x => x.QuestionId).NotEmpty();
RuleFor(x => x.Level).NotEmpty();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
using Module.Answers.Aggregators;
using Vote.Monitor.Core.RulesEngine;
using Vote.Monitor.Core.Services.FileStorage.Contracts;

namespace Feature.Form.Submissions.GetQuestionAggregatedByLevel;

public class Endpoint(
IAuthorizationService authorizationService,
INpgsqlConnectionFactory connectionFactory,
IFileStorageService fileStorageService) : Endpoint<Request, Results<Ok<Response>, NotFound>>
{
public override void Configure()
{
Post("/api/election-rounds/{electionRoundId}/form-submissions/{formId}/questions/{questionId}:aggregatedByLevel");
DontAutoTag();
Options(x => x.WithTags("form-submissions", "mobile"));
Summary(s =>
{
s.Summary = "Gets the aggregated answers to a question, broken down by the locations of a level (Level1..Level5)";
s.Description = "Returns 404 when the form is not available to the ngo or does not contain the question.";
});
Policies(PolicyNames.NgoAdminOrStaff);
}

public override async Task<Results<Ok<Response>, NotFound>> ExecuteAsync(Request req, CancellationToken ct)
{
var authorizationResult =
await authorizationService.AuthorizeAsync(User, new MonitoringNgoAdminOrStaffRequirement(req.ElectionRoundId));
if (!authorizationResult.Succeeded)
{
return TypedResults.NotFound();
}

var filter = new FilterSqlCompiler(SubmissionFilterFields.All, parameterPrefixer: FilterSqlCompiler.FunctionParameter)
.Build(req.Filter);

const string sql = """
SELECT * FROM "GetQuestionAggregateByLevel"(@electionRoundId, @ngoId, @dataSource, @formId, @questionId, @level, @filter, @filterParameters::jsonb)
""";

QuestionAggregateByLevel? aggregate;
using (var dbConnection = await connectionFactory.GetOpenConnectionAsync(ct))
{
aggregate = await dbConnection.QuerySingleOrDefaultAsync<QuestionAggregateByLevel>(new CommandDefinition(sql, new
{
electionRoundId = req.ElectionRoundId,
ngoId = req.NgoId,
dataSource = req.DataSource.ToString(),
formId = req.FormId,
questionId = req.QuestionId,
level = req.Level.Depth,
filter = filter.Sql,
filterParameters = filter.ToJsonParameters()
}, cancellationToken: ct));
}

if (aggregate is null)
{
return TypedResults.NotFound();
}

aggregate.Attachments = await fileStorageService.PresignAsync(aggregate.Attachments);

return TypedResults.Ok(new Response { QuestionAggregate = aggregate });
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using Vote.Monitor.Core.Models;
using Vote.Monitor.Core.Security;

namespace Feature.Form.Submissions.GetQuestionAggregatedByLevel;

public class Request : BaseFilterConditionsRequest
{
public Guid ElectionRoundId { get; set; }

[FromClaim(ApplicationClaimTypes.NgoId)]
public Guid NgoId { get; set; }

public Guid FormId { get; set; }

public Guid QuestionId { get; set; }

/// <summary>
/// The level to break the submissions down by.
/// </summary>
public LocationLevel Level { get; set; } = null!;

public DataSource DataSource { get; set; } = DataSource.Ngo;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using Module.Answers.Aggregators;

namespace Feature.Form.Submissions.GetQuestionAggregatedByLevel;

public class Response
{
public QuestionAggregateByLevel QuestionAggregate { get; set; } = null!;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
namespace Feature.Form.Submissions.GetQuestionAggregatedByLevel;

public class Validator : Validator<Request>
{
public Validator()
{
RuleFor(x => x.ElectionRoundId).NotEmpty();
RuleFor(x => x.NgoId).NotEmpty();
RuleFor(x => x.FormId).NotEmpty();
RuleFor(x => x.QuestionId).NotEmpty();
RuleFor(x => x.Level).NotEmpty();
RuleFor(x => x.DataSource).NotEmpty();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
using Dapper;
using Module.Answers.Aggregators;
using Vote.Monitor.Core.RulesEngine;
using Vote.Monitor.Domain.ConnectionFactory;
using Vote.Monitor.Domain.Queries;

namespace Feature.IncidentReports.GetQuestionAggregatedByLevel;

public class Endpoint(
IAuthorizationService authorizationService,
IFileStorageService fileStorageService,
INpgsqlConnectionFactory dbConnectionFactory)
: Endpoint<Request, Results<Ok<Response>, NotFound>>
{
public override void Configure()
{
Post("/api/election-rounds/{electionRoundId}/incident-reports/forms/{formId}/questions/{questionId}:aggregatedByLevel");
DontAutoTag();
Options(x => x.WithTags("incident-reports"));
Policies(PolicyNames.NgoAdminOrStaff);
Summary(s =>
{
s.Summary = "Gets the aggregated answers to an incident report question, broken down by the locations of a level (Level1..Level5)";
s.Description = "Returns 404 when the form does not belong to the ngo or does not contain the question.";
});
}

public override async Task<Results<Ok<Response>, NotFound>> ExecuteAsync(Request req, CancellationToken ct)
{
var authorizationResult =
await authorizationService.AuthorizeAsync(User, new MonitoringNgoAdminOrStaffRequirement(req.ElectionRoundId));
if (!authorizationResult.Succeeded)
{
return TypedResults.NotFound();
}

var filter = new FilterSqlCompiler(V2ReportFilterFields.IncidentReports, parameterPrefixer: FilterSqlCompiler.FunctionParameter)
.Build(req.Filter);

const string sql = """
SELECT * FROM "GetIncidentReportsQuestionAggregateByLevel"(@electionRoundId, @ngoId, @dataSource, @formId, @questionId, @level, @filter, @filterParameters::jsonb)
""";

QuestionAggregateByLevel? aggregate;
using (var dbConnection = await dbConnectionFactory.GetOpenConnectionAsync(ct))
{
aggregate = await dbConnection.QuerySingleOrDefaultAsync<QuestionAggregateByLevel>(new CommandDefinition(sql, new
{
electionRoundId = req.ElectionRoundId,
ngoId = req.NgoId,
dataSource = DataSource.Ngo.ToString(),
formId = req.FormId,
questionId = req.QuestionId,
level = req.Level.Depth,
filter = filter.Sql,
filterParameters = filter.ToJsonParameters()
}, cancellationToken: ct));
}

if (aggregate is null)
{
return TypedResults.NotFound();
}

aggregate.Attachments = await fileStorageService.PresignAsync(aggregate.Attachments);

return TypedResults.Ok(new Response { QuestionAggregate = aggregate });
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using Vote.Monitor.Core.Models;
using Vote.Monitor.Core.Security;

namespace Feature.IncidentReports.GetQuestionAggregatedByLevel;

public class Request : BaseFilterConditionsRequest
{
public Guid ElectionRoundId { get; set; }

[FromClaim(ApplicationClaimTypes.NgoId)]
public Guid NgoId { get; set; }

public Guid FormId { get; set; }

public Guid QuestionId { get; set; }

/// <summary>
/// The level to break the submissions down by.
/// </summary>
public LocationLevel Level { get; set; } = null!;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using Module.Answers.Aggregators;

namespace Feature.IncidentReports.GetQuestionAggregatedByLevel;

public class Response
{
public QuestionAggregateByLevel QuestionAggregate { get; set; } = null!;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
namespace Feature.IncidentReports.GetQuestionAggregatedByLevel;

public class Validator : Validator<Request>
{
public Validator()
{
RuleFor(x => x.ElectionRoundId).NotEmpty();
RuleFor(x => x.NgoId).NotEmpty();
RuleFor(x => x.FormId).NotEmpty();
RuleFor(x => x.QuestionId).NotEmpty();
RuleFor(x => x.Level).NotEmpty();
}
}
22 changes: 18 additions & 4 deletions api/src/Feature.MonitoringObservers/List/Endpoint.cs
Original file line number Diff line number Diff line change
Expand Up @@ -168,8 +168,15 @@ ORDER BY
CASE WHEN @sortExpression = 'Tags DESC' THEN "Tags" END DESC,

CASE WHEN @sortExpression = 'LatestActivityAt ASC' THEN "LatestActivityAt" END ASC,
CASE WHEN @sortExpression = 'LatestActivityAt DESC' THEN "LatestActivityAt" END DESC

CASE WHEN @sortExpression = 'LatestActivityAt DESC' THEN "LatestActivityAt" END DESC,

CASE WHEN @sortExpression = 'Status ASC' THEN "Status" END ASC,
CASE WHEN @sortExpression = 'Status DESC' THEN "Status" END DESC,

-- a stable order for the rows with the same sort value, so pages do not overlap
"DisplayName" ASC,
"Id" ASC

OFFSET @offset ROWS
FETCH NEXT @pageSize ROWS ONLY;
""";
Expand Down Expand Up @@ -206,7 +213,9 @@ private static string GetSortExpression(string? sortColumnName, bool isAscending

var sortOrder = isAscendingSorting ? "ASC" : "DESC";

if (string.Equals(sortColumnName, nameof(MonitoringObserverModel.DisplayName), StringComparison.InvariantCultureIgnoreCase))
// the web app's observer name column sorts by display name
if (string.Equals(sortColumnName, nameof(MonitoringObserverModel.DisplayName), StringComparison.InvariantCultureIgnoreCase) ||
string.Equals(sortColumnName, "ObserverName", StringComparison.InvariantCultureIgnoreCase))
{
return $"DisplayName {sortOrder}";
}
Expand All @@ -231,6 +240,11 @@ private static string GetSortExpression(string? sortColumnName, bool isAscending
return $"{nameof(MonitoringObserverModel.LatestActivityAt)} {sortOrder}";
}

return "ObserverName ASC";
if (string.Equals(sortColumnName, nameof(MonitoringObserverModel.Status), StringComparison.InvariantCultureIgnoreCase))
{
return $"{nameof(MonitoringObserverModel.Status)} {sortOrder}";
}

return "DisplayName ASC";
}
}
Loading
Loading