From 05b33d5a2ca3d4fb9d4ac65477399007833fb56f Mon Sep 17 00:00:00 2001 From: "ion.dormenco" Date: Tue, 29 Sep 2026 13:35:06 +0300 Subject: [PATCH 1/2] Add platform admin statistics endpoint and rename elections overview. Expose platform-wide metrics at GET /api/statistics/platform, and rename GetElectionsOverview to GetPlatformStatistics while keeping the existing overview API behavior. Co-authored-by: Cursor --- .../GetElectionsOverview/Endpoint.cs | 240 ------------------ .../CountryHistogramPoint.cs | 9 + .../GetPlatformAdminStatistics/Endpoint.cs | 168 ++++++++++++ .../GetPlatformAdminStatistics/NgosStats.cs | 7 + .../ObserversStats.cs | 7 + .../GetPlatformAdminStatistics/Response.cs | 18 ++ .../GetPlatformStatistics/Endpoint.cs | 240 ++++++++++++++++++ .../PollingStationVisitsView.cs | 3 +- .../Request.cs | 2 +- .../Response.cs | 2 +- .../Validator.cs | 2 +- ...=> GetPlatformStatisticsValidatorTests.cs} | 14 +- 12 files changed, 460 insertions(+), 252 deletions(-) delete mode 100644 api/src/Feature.Statistics/GetElectionsOverview/Endpoint.cs create mode 100644 api/src/Feature.Statistics/GetPlatformAdminStatistics/CountryHistogramPoint.cs create mode 100644 api/src/Feature.Statistics/GetPlatformAdminStatistics/Endpoint.cs create mode 100644 api/src/Feature.Statistics/GetPlatformAdminStatistics/NgosStats.cs create mode 100644 api/src/Feature.Statistics/GetPlatformAdminStatistics/ObserversStats.cs create mode 100644 api/src/Feature.Statistics/GetPlatformAdminStatistics/Response.cs create mode 100644 api/src/Feature.Statistics/GetPlatformStatistics/Endpoint.cs rename api/src/Feature.Statistics/{GetElectionsOverview => GetPlatformStatistics}/PollingStationVisitsView.cs (86%) rename api/src/Feature.Statistics/{GetElectionsOverview => GetPlatformStatistics}/Request.cs (75%) rename api/src/Feature.Statistics/{GetElectionsOverview => GetPlatformStatistics}/Response.cs (95%) rename api/src/Feature.Statistics/{GetElectionsOverview => GetPlatformStatistics}/Validator.cs (91%) rename api/tests/Feature.Statistics.UnitTests/Validators/{GetElectionsOverviewValidatorTests.cs => GetPlatformStatisticsValidatorTests.cs} (76%) diff --git a/api/src/Feature.Statistics/GetElectionsOverview/Endpoint.cs b/api/src/Feature.Statistics/GetElectionsOverview/Endpoint.cs deleted file mode 100644 index 5de0df4fa..000000000 --- a/api/src/Feature.Statistics/GetElectionsOverview/Endpoint.cs +++ /dev/null @@ -1,240 +0,0 @@ -using Dapper; -using Feature.Statistics.Options; -using Microsoft.Extensions.Options; -using Vote.Monitor.Domain.ConnectionFactory; -using ZiggyCreatures.Caching.Fusion; - -namespace Feature.Statistics.GetElectionsOverview; - -public class Endpoint( - INpgsqlConnectionFactory dbConnectionFactory, - IFusionCache cache, - IOptions options) : Endpoint -{ - private readonly StatisticsFeatureOptions _options = options.Value; - - public override void Configure() - { - Get("/api/statistics/overview"); - AllowAnonymous(); - DontAutoTag(); - Options(x => x.WithTags("statistics")); - Summary(s => - { - s.Summary = "Statistics for an list of election round"; - }); - } - - public override async Task ExecuteAsync(Request req, CancellationToken ct) - { - var electionRoundKey = string.Join(",", (req.ElectionRoundIds ?? []).OrderBy(x => x)); - var cacheKey = $"statistics-overview-{electionRoundKey}"; - - return await cache.GetOrSetAsync( - cacheKey, - async _ => await GetOverviewStatisticsAsync(req, ct), - cacheOptions => cacheOptions.SetDuration(TimeSpan.FromMinutes(_options.CacheDurationInMinutes)), - token: ct); - } - - private async Task GetOverviewStatisticsAsync(Request req, CancellationToken ct) - { - var statisticsQuery = """ - -- number of monitoring observers with active account - SELECT - COUNT(*) - FROM - "MonitoringObservers" MO - LEFT JOIN "AspNetUsers" U ON U."Id" = MO."ObserverId" - WHERE - "ElectionRoundId" = ANY (@electionRoundIds) - AND U."Status" = 'Active' - AND MO."Status" = 'Active'; - ------------------------------ - - -- number of ngos with active account - SELECT - COUNT(*) - FROM - "MonitoringNgos" - WHERE - "ElectionRoundId" = ANY (@electionRoundIds) - AND "Status" = 'Active'; - ------------------------------ - - -- total number of polling stations - SELECT - COUNT(*) - FROM - "PollingStations" - WHERE - "ElectionRoundId" = ANY (@electionRoundIds); - ------------------------------ - - -- visited stations stats - SELECT - COUNT(DISTINCT T."PollingStationId") AS "NumberOfVisitedPollingStations", - COUNT(DISTINCT PS."Level1") AS "NumberOfLevel1Covered", - COUNT(DISTINCT CONCAT(PS."Level1", '-', PS."Level2")) AS "NumberOfLevel2Covered", - COUNT( - DISTINCT CONCAT(PS."Level1", '-', PS."Level2", '-', PS."Level3") - ) AS "NumberOfLevel3Covered", - COUNT( - DISTINCT CONCAT( - PS."Level1", - '-', - PS."Level2", - '-', - PS."Level3", - '-', - PS."Level4" - ) - ) AS "NumberOfLevel4Covered", - COUNT( - DISTINCT CONCAT( - PS."Level1", - '-', - PS."Level2", - '-', - PS."Level3", - '-', - PS."Level4", - '-', - PS."Level5" - ) - ) AS "NumberOfLevel5Covered" - FROM - ( - SELECT - FS."PollingStationId" - FROM - "FormSubmissions" FS - INNER JOIN "Forms" F ON F."Id" = FS."FormId" - WHERE - FS."ElectionRoundId" = ANY (@electionRoundIds) - AND F."Status" <> 'Drafted' - AND FS."NumberOfQuestionsAnswered" > 0 - UNION - SELECT - "PollingStationId" - FROM - "PollingStationInformation" - WHERE - "ElectionRoundId" = ANY (@electionRoundIds) - AND "NumberOfQuestionsAnswered" > 0 - ) AS T - INNER JOIN "PollingStations" PS ON T."PollingStationId" = PS."Id"; - ----------------------------- - - -- number of form submissions - SELECT - ( - SELECT - COUNT(1) - FROM - "FormSubmissions" FS - INNER JOIN "Forms" F ON F."Id" = FS."FormId" - WHERE - FS."ElectionRoundId" = ANY (@electionRoundIds) - AND FS."NumberOfQuestionsAnswered" > 0 - AND F."Status" <> 'Drafted' - ) + ( - SELECT - COUNT(1) - FROM - "PollingStationInformation" - WHERE - "ElectionRoundId" = ANY (@electionRoundIds) - AND "NumberOfQuestionsAnswered" > 0 - ) AS "NumberOfQuestionsAnswered"; - ----------------------------- - - -- number of questions answered - SELECT - ( - SELECT - SUM("NumberOfQuestionsAnswered") - FROM - "FormSubmissions" FS - INNER JOIN "Forms" F ON F."Id" = FS."FormId" - WHERE - FS."ElectionRoundId" = ANY (@electionRoundIds) - AND FS."NumberOfQuestionsAnswered" > 0 - AND F."Status" <> 'Drafted' - ) + ( - SELECT - SUM("NumberOfQuestionsAnswered") - FROM - "PollingStationInformation" - WHERE - "ElectionRoundId" = ANY (@electionRoundIds) - ) AS "NumberOfFormSubmissions"; - ------------------------------ - - SELECT - SUM("NumberOfFlaggedAnswers") - FROM - "FormSubmissions" FS - INNER JOIN "Forms" F ON F."Id" = FS."FormId" - WHERE - FS."ElectionRoundId" = ANY (@electionRoundIds) - AND "NumberOfQuestionsAnswered" > 0 - AND F."Status" <> 'Drafted'; - ------------------------------ - - -- minutes monitoring - SELECT - SUM("ComputeMinutesMonitoring"("ArrivalTime", "DepartureTime", "Breaks")) - FROM - "PollingStationInformation" - WHERE - "ElectionRoundId" = ANY (@electionRoundIds); - ------------------------------ - """; - - var queryArgs = new - { - electionRoundIds = req.ElectionRoundIds - }; - - int numberOfMonitoringObservers; - int numberOfMonitoringNgos; - int totalNumberOfPollingStations; - PollingStationVisitsView pollingStationVisitsView = null!; - int numberOfSubmittedForms; - int numberOfAnsweredQuestions; - int numberOfFlaggedAnswers; - int minutesMonitoring; - - using (var dbConnection = await dbConnectionFactory.GetOpenConnectionAsync(ct)) - { - using var multi = await dbConnection.QueryMultipleAsync(statisticsQuery, queryArgs); - - numberOfMonitoringObservers = multi.ReadSingle(); - numberOfMonitoringNgos = multi.ReadSingle(); - totalNumberOfPollingStations = multi.ReadSingle(); - pollingStationVisitsView = multi.ReadSingle(); - numberOfSubmittedForms = multi.ReadSingle(); - numberOfAnsweredQuestions = multi.ReadSingle(); - numberOfFlaggedAnswers = multi.ReadSingle(); - minutesMonitoring = multi.ReadSingle(); - } - - return new Response - { - Observers = numberOfMonitoringObservers, - Ngos = numberOfMonitoringNgos, - PollingStations = totalNumberOfPollingStations, - VisitedPollingStations = pollingStationVisitsView?.NumberOfVisitedPollingStations ?? 0, - Level1Visited = pollingStationVisitsView?.NumberOfLevel1Covered ?? 0, - Level2Visited = pollingStationVisitsView?.NumberOfLevel2Covered ?? 0, - Level3Visited = pollingStationVisitsView?.NumberOfLevel3Covered ?? 0, - Level4Visited = pollingStationVisitsView?.NumberOfLevel4Covered ?? 0, - Level5Visited = pollingStationVisitsView?.NumberOfLevel5Covered ?? 0, - StartedForms = numberOfSubmittedForms, - QuestionsAnswered = numberOfAnsweredQuestions, - FlaggedAnswers = numberOfFlaggedAnswers, - MinutesMonitoring = minutesMonitoring - }; - } -} diff --git a/api/src/Feature.Statistics/GetPlatformAdminStatistics/CountryHistogramPoint.cs b/api/src/Feature.Statistics/GetPlatformAdminStatistics/CountryHistogramPoint.cs new file mode 100644 index 000000000..5ee532701 --- /dev/null +++ b/api/src/Feature.Statistics/GetPlatformAdminStatistics/CountryHistogramPoint.cs @@ -0,0 +1,9 @@ +namespace Feature.Statistics.GetPlatformAdminStatistics; + +public class CountryHistogramPoint +{ + public Guid CountryId { get; set; } + public string CountryName { get; set; } = string.Empty; + public string Iso2 { get; set; } = string.Empty; + public int NumberOfElections { get; set; } +} diff --git a/api/src/Feature.Statistics/GetPlatformAdminStatistics/Endpoint.cs b/api/src/Feature.Statistics/GetPlatformAdminStatistics/Endpoint.cs new file mode 100644 index 000000000..beeb14c8b --- /dev/null +++ b/api/src/Feature.Statistics/GetPlatformAdminStatistics/Endpoint.cs @@ -0,0 +1,168 @@ +using Authorization.Policies; +using Dapper; +using Feature.Statistics.Options; +using Microsoft.Extensions.Options; +using Vote.Monitor.Domain.ConnectionFactory; +using ZiggyCreatures.Caching.Fusion; + +namespace Feature.Statistics.GetPlatformAdminStatistics; + +public class Endpoint( + INpgsqlConnectionFactory dbConnectionFactory, + IFusionCache cache, + IOptions options) : EndpointWithoutRequest +{ + private const string CacheKey = "statistics-platform-admin"; + private readonly StatisticsFeatureOptions _options = options.Value; + + public override void Configure() + { + Get("/api/statistics/platform"); + DontAutoTag(); + Options(x => x.WithTags("statistics")); + Policies(PolicyNames.PlatformAdminsOnly); + Summary(s => + { + s.Summary = "Platform-wide statistics for platform admins"; + }); + } + + public override async Task ExecuteAsync(CancellationToken ct) + { + return await cache.GetOrSetAsync( + CacheKey, + async _ => await GetPlatformStatisticsAsync(ct), + cacheOptions => cacheOptions.SetDuration(TimeSpan.FromMinutes(_options.CacheDurationInMinutes)), + token: ct); + } + + private async Task GetPlatformStatisticsAsync(CancellationToken ct) + { + const string sql = + """ + SELECT + C."Id" AS "CountryId", + C."Name" AS "CountryName", + C."Iso2" AS "Iso2", + COUNT(ER."Id") AS "NumberOfElections" + FROM "ElectionRounds" ER + INNER JOIN "Countries" C ON C."Id" = ER."CountryId" + GROUP BY C."Id", C."Name", C."Iso2" + ORDER BY C."Name"; + ------------------------------ + + SELECT + COUNT(*) FILTER (WHERE U."Status" = 'Active') AS "ActiveObservers", + COUNT(*) FILTER (WHERE U."Status" = 'Deactivated') AS "SuspendedObservers" + FROM "Observers" O + INNER JOIN "AspNetUsers" U ON U."Id" = O."Id"; + ------------------------------ + + SELECT + COUNT(*) FILTER (WHERE "Status" = 'Activated') AS "ActiveNgos", + COUNT(*) FILTER (WHERE "Status" = 'Deactivated') AS "DeactivatedNgos" + FROM "Ngos"; + ------------------------------ + + SELECT COUNT(*) FROM "ElectionRounds"; + ------------------------------ + + SELECT COUNT(*) FROM "PollingStations"; + ------------------------------ + + SELECT COUNT(DISTINCT T."PollingStationId") + FROM ( + SELECT FS."PollingStationId" + FROM "FormSubmissions" FS + INNER JOIN "Forms" F ON F."Id" = FS."FormId" + WHERE F."Status" <> 'Drafted' + AND FS."NumberOfQuestionsAnswered" > 0 + UNION + SELECT PSI."PollingStationId" + FROM "PollingStationInformation" PSI + WHERE PSI."NumberOfQuestionsAnswered" > 0 + ) AS T; + ------------------------------ + + SELECT COALESCE(SUM("ComputeMinutesMonitoring"("ArrivalTime", "DepartureTime", "Breaks")), 0) + FROM "PollingStationInformation"; + ------------------------------ + + SELECT + ( + SELECT COUNT(1) + FROM "FormSubmissions" FS + INNER JOIN "Forms" F ON F."Id" = FS."FormId" + WHERE FS."NumberOfQuestionsAnswered" > 0 + AND F."Status" <> 'Drafted' + ) + ( + SELECT COUNT(1) + FROM "PollingStationInformation" + WHERE "NumberOfQuestionsAnswered" > 0 + ); + ------------------------------ + + SELECT + ( + SELECT COALESCE(SUM("NumberOfQuestionsAnswered"), 0) + FROM "FormSubmissions" FS + INNER JOIN "Forms" F ON F."Id" = FS."FormId" + WHERE FS."NumberOfQuestionsAnswered" > 0 + AND F."Status" <> 'Drafted' + ) + ( + SELECT COALESCE(SUM("NumberOfQuestionsAnswered"), 0) + FROM "PollingStationInformation" + ); + ------------------------------ + + SELECT COALESCE(SUM(FS."NumberOfFlaggedAnswers"), 0) + FROM "FormSubmissions" FS + INNER JOIN "Forms" F ON F."Id" = FS."FormId" + WHERE FS."NumberOfQuestionsAnswered" > 0 + AND F."Status" <> 'Drafted'; + ------------------------------ + + SELECT COUNT(*) FROM "QuickReports"; + ------------------------------ + + SELECT COUNT(*) FROM "IncidentReports"; + ------------------------------ + + SELECT COUNT(*) FROM "CitizenReports"; + """; + + using var dbConnection = await dbConnectionFactory.GetOpenConnectionAsync(ct); + using var multi = await dbConnection.QueryMultipleAsync(sql); + + var countriesHistogram = multi.Read().ToList(); + var observers = multi.ReadSingle(); + var ngos = multi.ReadSingle(); + var numberOfElections = multi.ReadSingle(); + var numberOfPollingStations = multi.ReadSingle(); + var numberOfVisitedPollingStations = multi.ReadSingle(); + var numberOfMinutesMonitoring = multi.ReadSingle(); + var numberOfFormSubmissions = multi.ReadSingle(); + var numberOfQuestionsAnswered = multi.ReadSingle(); + var numberOfFlaggedAnswers = multi.ReadSingle(); + var numberOfQuickReports = multi.ReadSingle(); + var numberOfIncidentReports = multi.ReadSingle(); + var numberOfCitizenReports = multi.ReadSingle(); + + return new Response + { + CountriesHistogram = countriesHistogram, + Observers = observers, + Ngos = ngos, + NumberOfElections = numberOfElections, + NumberOfPollingStations = numberOfPollingStations, + NumberOfVisitedPollingStations = numberOfVisitedPollingStations, + NumberOfMinutesMonitoring = numberOfMinutesMonitoring, + NumberOfFormSubmissions = numberOfFormSubmissions, + NumberOfQuestionsAnswered = numberOfQuestionsAnswered, + NumberOfFlaggedAnswers = numberOfFlaggedAnswers, + NumberOfQuickReports = numberOfQuickReports, + NumberOfIncidentReports = numberOfIncidentReports, + NumberOfCitizenReports = numberOfCitizenReports + }; + } +} diff --git a/api/src/Feature.Statistics/GetPlatformAdminStatistics/NgosStats.cs b/api/src/Feature.Statistics/GetPlatformAdminStatistics/NgosStats.cs new file mode 100644 index 000000000..8aa82226f --- /dev/null +++ b/api/src/Feature.Statistics/GetPlatformAdminStatistics/NgosStats.cs @@ -0,0 +1,7 @@ +namespace Feature.Statistics.GetPlatformAdminStatistics; + +public class NgosStats +{ + public int ActiveNgos { get; set; } + public int DeactivatedNgos { get; set; } +} diff --git a/api/src/Feature.Statistics/GetPlatformAdminStatistics/ObserversStats.cs b/api/src/Feature.Statistics/GetPlatformAdminStatistics/ObserversStats.cs new file mode 100644 index 000000000..29672f40c --- /dev/null +++ b/api/src/Feature.Statistics/GetPlatformAdminStatistics/ObserversStats.cs @@ -0,0 +1,7 @@ +namespace Feature.Statistics.GetPlatformAdminStatistics; + +public class ObserversStats +{ + public int ActiveObservers { get; set; } + public int SuspendedObservers { get; set; } +} diff --git a/api/src/Feature.Statistics/GetPlatformAdminStatistics/Response.cs b/api/src/Feature.Statistics/GetPlatformAdminStatistics/Response.cs new file mode 100644 index 000000000..a4e02dd5b --- /dev/null +++ b/api/src/Feature.Statistics/GetPlatformAdminStatistics/Response.cs @@ -0,0 +1,18 @@ +namespace Feature.Statistics.GetPlatformAdminStatistics; + +public class Response +{ + public List CountriesHistogram { get; set; } = []; + public ObserversStats Observers { get; set; } = new(); + public NgosStats Ngos { get; set; } = new(); + public int NumberOfElections { get; set; } + public int NumberOfPollingStations { get; set; } + public int NumberOfVisitedPollingStations { get; set; } + public int NumberOfMinutesMonitoring { get; set; } + public int NumberOfFormSubmissions { get; set; } + public int NumberOfQuestionsAnswered { get; set; } + public int NumberOfFlaggedAnswers { get; set; } + public int NumberOfQuickReports { get; set; } + public int NumberOfIncidentReports { get; set; } + public int NumberOfCitizenReports { get; set; } +} diff --git a/api/src/Feature.Statistics/GetPlatformStatistics/Endpoint.cs b/api/src/Feature.Statistics/GetPlatformStatistics/Endpoint.cs new file mode 100644 index 000000000..bd88bdda8 --- /dev/null +++ b/api/src/Feature.Statistics/GetPlatformStatistics/Endpoint.cs @@ -0,0 +1,240 @@ +using Dapper; +using Feature.Statistics.Options; +using Microsoft.Extensions.Options; +using Vote.Monitor.Domain.ConnectionFactory; +using ZiggyCreatures.Caching.Fusion; + +namespace Feature.Statistics.GetPlatformStatistics; + +public class Endpoint( + INpgsqlConnectionFactory dbConnectionFactory, + IFusionCache cache, + IOptions options) : Endpoint +{ + private readonly StatisticsFeatureOptions _options = options.Value; + + public override void Configure() + { + Get("/api/statistics/overview"); + AllowAnonymous(); + DontAutoTag(); + Options(x => x.WithTags("statistics")); + Summary(s => + { + s.Summary = "Statistics for an list of election round"; + }); + } + + public override async Task ExecuteAsync(Request req, CancellationToken ct) + { + var electionRoundKey = string.Join(",", (req.ElectionRoundIds ?? []).OrderBy(x => x)); + var cacheKey = $"statistics-overview-{electionRoundKey}"; + + return await cache.GetOrSetAsync( + cacheKey, + async _ => await GetOverviewStatisticsAsync(req, ct), + cacheOptions => cacheOptions.SetDuration(TimeSpan.FromMinutes(_options.CacheDurationInMinutes)), + token: ct); + } + + private async Task GetOverviewStatisticsAsync(Request req, CancellationToken ct) + { + var statisticsQuery = """ + -- number of monitoring observers with active account + SELECT + COUNT(*) + FROM + "MonitoringObservers" MO + LEFT JOIN "AspNetUsers" U ON U."Id" = MO."ObserverId" + WHERE + "ElectionRoundId" = ANY (@electionRoundIds) + AND U."Status" = 'Active' + AND MO."Status" = 'Active'; + ------------------------------ + + -- number of ngos with active account + SELECT + COUNT(*) + FROM + "MonitoringNgos" + WHERE + "ElectionRoundId" = ANY (@electionRoundIds) + AND "Status" = 'Active'; + ------------------------------ + + -- total number of polling stations + SELECT + COUNT(*) + FROM + "PollingStations" + WHERE + "ElectionRoundId" = ANY (@electionRoundIds); + ------------------------------ + + -- visited stations stats + SELECT + COUNT(DISTINCT T."PollingStationId") AS "NumberOfVisitedPollingStations", + COUNT(DISTINCT PS."Level1") AS "NumberOfLevel1Covered", + COUNT(DISTINCT CONCAT(PS."Level1", '-', PS."Level2")) AS "NumberOfLevel2Covered", + COUNT( + DISTINCT CONCAT(PS."Level1", '-', PS."Level2", '-', PS."Level3") + ) AS "NumberOfLevel3Covered", + COUNT( + DISTINCT CONCAT( + PS."Level1", + '-', + PS."Level2", + '-', + PS."Level3", + '-', + PS."Level4" + ) + ) AS "NumberOfLevel4Covered", + COUNT( + DISTINCT CONCAT( + PS."Level1", + '-', + PS."Level2", + '-', + PS."Level3", + '-', + PS."Level4", + '-', + PS."Level5" + ) + ) AS "NumberOfLevel5Covered" + FROM + ( + SELECT + FS."PollingStationId" + FROM + "FormSubmissions" FS + INNER JOIN "Forms" F ON F."Id" = FS."FormId" + WHERE + FS."ElectionRoundId" = ANY (@electionRoundIds) + AND F."Status" <> 'Drafted' + AND FS."NumberOfQuestionsAnswered" > 0 + UNION + SELECT + "PollingStationId" + FROM + "PollingStationInformation" + WHERE + "ElectionRoundId" = ANY (@electionRoundIds) + AND "NumberOfQuestionsAnswered" > 0 + ) AS T + INNER JOIN "PollingStations" PS ON T."PollingStationId" = PS."Id"; + ----------------------------- + + -- number of form submissions + SELECT + ( + SELECT + COUNT(1) + FROM + "FormSubmissions" FS + INNER JOIN "Forms" F ON F."Id" = FS."FormId" + WHERE + FS."ElectionRoundId" = ANY (@electionRoundIds) + AND FS."NumberOfQuestionsAnswered" > 0 + AND F."Status" <> 'Drafted' + ) + ( + SELECT + COUNT(1) + FROM + "PollingStationInformation" + WHERE + "ElectionRoundId" = ANY (@electionRoundIds) + AND "NumberOfQuestionsAnswered" > 0 + ) AS "NumberOfQuestionsAnswered"; + ----------------------------- + + -- number of questions answered + SELECT + ( + SELECT + SUM("NumberOfQuestionsAnswered") + FROM + "FormSubmissions" FS + INNER JOIN "Forms" F ON F."Id" = FS."FormId" + WHERE + FS."ElectionRoundId" = ANY (@electionRoundIds) + AND FS."NumberOfQuestionsAnswered" > 0 + AND F."Status" <> 'Drafted' + ) + ( + SELECT + SUM("NumberOfQuestionsAnswered") + FROM + "PollingStationInformation" + WHERE + "ElectionRoundId" = ANY (@electionRoundIds) + ) AS "NumberOfFormSubmissions"; + ------------------------------ + + SELECT + SUM("NumberOfFlaggedAnswers") + FROM + "FormSubmissions" FS + INNER JOIN "Forms" F ON F."Id" = FS."FormId" + WHERE + FS."ElectionRoundId" = ANY (@electionRoundIds) + AND "NumberOfQuestionsAnswered" > 0 + AND F."Status" <> 'Drafted'; + ------------------------------ + + -- minutes monitoring + SELECT + SUM("ComputeMinutesMonitoring"("ArrivalTime", "DepartureTime", "Breaks")) + FROM + "PollingStationInformation" + WHERE + "ElectionRoundId" = ANY (@electionRoundIds); + ------------------------------ + """; + + var queryArgs = new + { + electionRoundIds = req.ElectionRoundIds + }; + + int numberOfMonitoringObservers; + int numberOfMonitoringNgos; + int totalNumberOfPollingStations; + PollingStationVisitsView pollingStationVisitsView = null!; + int numberOfSubmittedForms; + int numberOfAnsweredQuestions; + int numberOfFlaggedAnswers; + int minutesMonitoring; + + using (var dbConnection = await dbConnectionFactory.GetOpenConnectionAsync(ct)) + { + using var multi = await dbConnection.QueryMultipleAsync(statisticsQuery, queryArgs); + + numberOfMonitoringObservers = multi.ReadSingle(); + numberOfMonitoringNgos = multi.ReadSingle(); + totalNumberOfPollingStations = multi.ReadSingle(); + pollingStationVisitsView = multi.ReadSingle(); + numberOfSubmittedForms = multi.ReadSingle(); + numberOfAnsweredQuestions = multi.ReadSingle(); + numberOfFlaggedAnswers = multi.ReadSingle(); + minutesMonitoring = multi.ReadSingle(); + } + + return new Response + { + Observers = numberOfMonitoringObservers, + Ngos = numberOfMonitoringNgos, + PollingStations = totalNumberOfPollingStations, + VisitedPollingStations = pollingStationVisitsView?.NumberOfVisitedPollingStations ?? 0, + Level1Visited = pollingStationVisitsView?.NumberOfLevel1Covered ?? 0, + Level2Visited = pollingStationVisitsView?.NumberOfLevel2Covered ?? 0, + Level3Visited = pollingStationVisitsView?.NumberOfLevel3Covered ?? 0, + Level4Visited = pollingStationVisitsView?.NumberOfLevel4Covered ?? 0, + Level5Visited = pollingStationVisitsView?.NumberOfLevel5Covered ?? 0, + StartedForms = numberOfSubmittedForms, + QuestionsAnswered = numberOfAnsweredQuestions, + FlaggedAnswers = numberOfFlaggedAnswers, + MinutesMonitoring = minutesMonitoring + }; + } +} diff --git a/api/src/Feature.Statistics/GetElectionsOverview/PollingStationVisitsView.cs b/api/src/Feature.Statistics/GetPlatformStatistics/PollingStationVisitsView.cs similarity index 86% rename from api/src/Feature.Statistics/GetElectionsOverview/PollingStationVisitsView.cs rename to api/src/Feature.Statistics/GetPlatformStatistics/PollingStationVisitsView.cs index be679371d..73def2fb8 100644 --- a/api/src/Feature.Statistics/GetElectionsOverview/PollingStationVisitsView.cs +++ b/api/src/Feature.Statistics/GetPlatformStatistics/PollingStationVisitsView.cs @@ -1,4 +1,4 @@ -namespace Feature.Statistics.GetElectionsOverview; +namespace Feature.Statistics.GetPlatformStatistics; public class PollingStationVisitsView { @@ -8,5 +8,4 @@ public class PollingStationVisitsView public int NumberOfLevel3Covered { get; set; } public int NumberOfLevel4Covered { get; set; } public int NumberOfLevel5Covered { get; set; } - } diff --git a/api/src/Feature.Statistics/GetElectionsOverview/Request.cs b/api/src/Feature.Statistics/GetPlatformStatistics/Request.cs similarity index 75% rename from api/src/Feature.Statistics/GetElectionsOverview/Request.cs rename to api/src/Feature.Statistics/GetPlatformStatistics/Request.cs index 5e52c6004..5df5c9c85 100644 --- a/api/src/Feature.Statistics/GetElectionsOverview/Request.cs +++ b/api/src/Feature.Statistics/GetPlatformStatistics/Request.cs @@ -1,4 +1,4 @@ -namespace Feature.Statistics.GetElectionsOverview; +namespace Feature.Statistics.GetPlatformStatistics; public class Request { diff --git a/api/src/Feature.Statistics/GetElectionsOverview/Response.cs b/api/src/Feature.Statistics/GetPlatformStatistics/Response.cs similarity index 95% rename from api/src/Feature.Statistics/GetElectionsOverview/Response.cs rename to api/src/Feature.Statistics/GetPlatformStatistics/Response.cs index 3e5d67988..f074509fa 100644 --- a/api/src/Feature.Statistics/GetElectionsOverview/Response.cs +++ b/api/src/Feature.Statistics/GetPlatformStatistics/Response.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace Feature.Statistics.GetElectionsOverview; +namespace Feature.Statistics.GetPlatformStatistics; public class Response { diff --git a/api/src/Feature.Statistics/GetElectionsOverview/Validator.cs b/api/src/Feature.Statistics/GetPlatformStatistics/Validator.cs similarity index 91% rename from api/src/Feature.Statistics/GetElectionsOverview/Validator.cs rename to api/src/Feature.Statistics/GetPlatformStatistics/Validator.cs index f216a259f..858715cf4 100644 --- a/api/src/Feature.Statistics/GetElectionsOverview/Validator.cs +++ b/api/src/Feature.Statistics/GetPlatformStatistics/Validator.cs @@ -1,7 +1,7 @@ using Feature.Statistics.Options; using Microsoft.Extensions.Options; -namespace Feature.Statistics.GetElectionsOverview; +namespace Feature.Statistics.GetPlatformStatistics; public class Validator : Validator { diff --git a/api/tests/Feature.Statistics.UnitTests/Validators/GetElectionsOverviewValidatorTests.cs b/api/tests/Feature.Statistics.UnitTests/Validators/GetPlatformStatisticsValidatorTests.cs similarity index 76% rename from api/tests/Feature.Statistics.UnitTests/Validators/GetElectionsOverviewValidatorTests.cs rename to api/tests/Feature.Statistics.UnitTests/Validators/GetPlatformStatisticsValidatorTests.cs index ab74cd655..64c1af934 100644 --- a/api/tests/Feature.Statistics.UnitTests/Validators/GetElectionsOverviewValidatorTests.cs +++ b/api/tests/Feature.Statistics.UnitTests/Validators/GetPlatformStatisticsValidatorTests.cs @@ -4,11 +4,11 @@ namespace Feature.Statistics.UnitTests.Validators; -public class GetElectionsOverviewValidatorTests +public class GetPlatformStatisticsValidatorTests { private const string API_KEY = "secret-api-key"; - private readonly GetElectionsOverview.Validator _validator = Factory.CreateValidator( + private readonly GetPlatformStatistics.Validator _validator = Factory.CreateValidator( sp => { sp.Configure(x => @@ -21,7 +21,7 @@ public class GetElectionsOverviewValidatorTests public void Validation_ShouldFail_When_ElectionRoundIds_Empty() { // Arrange - var request = new GetElectionsOverview.Request { ElectionRoundIds = [] }; + var request = new GetPlatformStatistics.Request { ElectionRoundIds = [] }; // Act var result = _validator.TestValidate(request); @@ -34,7 +34,7 @@ public void Validation_ShouldFail_When_ElectionRoundIds_Empty() public void Validation_ShouldFail_When_ElectionRoundIds_Contains_EmptyId() { // Arrange - var request = new GetElectionsOverview.Request { ElectionRoundIds = [Guid.NewGuid(), Guid.Empty] }; + var request = new GetPlatformStatistics.Request { ElectionRoundIds = [Guid.NewGuid(), Guid.Empty] }; // Act var result = _validator.TestValidate(request); @@ -48,7 +48,7 @@ public void Validation_ShouldFail_When_ElectionRoundIds_Contains_EmptyId() public void Validation_ShouldFail_When_ApiKey_Empty(string emptyValue) { // Arrange - var request = new GetElectionsOverview.Request { ApiKey = emptyValue}; + var request = new GetPlatformStatistics.Request { ApiKey = emptyValue}; // Act var result = _validator.TestValidate(request); @@ -61,7 +61,7 @@ public void Validation_ShouldFail_When_ApiKey_Empty(string emptyValue) public void Validation_ShouldFail_When_ApiKey_Invalid() { // Arrange - var request = new GetElectionsOverview.Request { ApiKey = "invalid-value"}; + var request = new GetPlatformStatistics.Request { ApiKey = "invalid-value"}; // Act var result = _validator.TestValidate(request); @@ -74,7 +74,7 @@ public void Validation_ShouldFail_When_ApiKey_Invalid() public void Validation_ShouldPass_When_Request_Valid() { // Arrange - var request = new GetElectionsOverview.Request + var request = new GetPlatformStatistics.Request { ApiKey = API_KEY, ElectionRoundIds = [Guid.NewGuid()] From bd5e016401b110207802e28a2794416b4125afc4 Mon Sep 17 00:00:00 2001 From: Ion Dormenco Date: Tue, 29 Sep 2026 14:01:27 +0300 Subject: [PATCH 2/2] Potential fix for pull request finding 'CodeQL / Useless assignment to local variable' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- api/src/Feature.Statistics/GetPlatformStatistics/Endpoint.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/Feature.Statistics/GetPlatformStatistics/Endpoint.cs b/api/src/Feature.Statistics/GetPlatformStatistics/Endpoint.cs index bd88bdda8..82a1342c3 100644 --- a/api/src/Feature.Statistics/GetPlatformStatistics/Endpoint.cs +++ b/api/src/Feature.Statistics/GetPlatformStatistics/Endpoint.cs @@ -200,7 +200,7 @@ AND F."Status" <> 'Drafted' int numberOfMonitoringObservers; int numberOfMonitoringNgos; int totalNumberOfPollingStations; - PollingStationVisitsView pollingStationVisitsView = null!; + PollingStationVisitsView pollingStationVisitsView; int numberOfSubmittedForms; int numberOfAnsweredQuestions; int numberOfFlaggedAnswers;