diff --git a/.github/actions/setup-rust-tests/action.yml b/.github/actions/setup-rust-tests/action.yml new file mode 100644 index 00000000000..e611537a168 --- /dev/null +++ b/.github/actions/setup-rust-tests/action.yml @@ -0,0 +1,51 @@ +# SPDX-FileCopyrightText: 2025 Sequent Tech Inc +# +# SPDX-License-Identifier: AGPL-3.0-only + +name: Set up Rust tests +description: Install the Rust toolchain, restore Cargo caches, and install native test dependencies + +inputs: + cargo-build-name: + description: Stable name used to isolate the Cargo build cache + required: true + cargo-build-path: + description: Path to the package Cargo target directory + required: true + cargo-lock-path: + description: Cargo lockfile used to invalidate the package build cache + required: true + +runs: + using: composite + steps: + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + toolchain: 1.96.0 + components: rustfmt + targets: x86_64-unknown-linux-musl + + - name: Cache Cargo registry + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-registry- + + - name: Cache Cargo build + uses: actions/cache@v4 + with: + path: ${{ inputs.cargo-build-path }} + key: ${{ runner.os }}-cargo-test-${{ inputs.cargo-build-name }}-${{ hashFiles(inputs.cargo-lock-path) }} + restore-keys: | + ${{ runner.os }}-cargo-test-${{ inputs.cargo-build-name }}- + + - name: Install system dependencies + shell: bash + run: | + sudo apt-get update + sudo apt-get install -y build-essential pkg-config libssl-dev protobuf-compiler libprotobuf-dev diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 530acba3896..762dea5017b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -18,15 +18,15 @@ jobs: matrix: include: #- service: braid # these take too long! + - service: electoral-log - service: harvest - service: strand - service: immu-board - service: immudb-rs - service: sequent-core extra: --features keycloak,default_features + - service: step-cli - service: velvet - - service: windmill - build_jobs: 2 - service: wrap-map-err fail-fast: false @@ -34,35 +34,12 @@ jobs: - name: Check out code uses: actions/checkout@v4 - - name: Install Rust - uses: dtolnay/rust-toolchain@stable + - name: Set up Rust tests + uses: ./.github/actions/setup-rust-tests with: - toolchain: 1.96.0 - components: rustfmt - targets: x86_64-unknown-linux-musl - - - name: Cache Cargo registry - uses: actions/cache@v4 - with: - path: | - ~/.cargo/registry - ~/.cargo/git - key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - ${{ runner.os }}-cargo-registry- - - - name: Cache Cargo build - uses: actions/cache@v4 - with: - path: packages/${{ matrix.service }}/target - key: ${{ runner.os }}-cargo-test-${{ matrix.service }}-${{ hashFiles('packages/${{ matrix.service }}/Cargo.lock') }} - restore-keys: | - ${{ runner.os }}-cargo-test-${{ matrix.service }}- - - - name: Install system dependencies - run: | - sudo apt-get update - sudo apt-get install -y build-essential pkg-config libssl-dev protobuf-compiler libprotobuf-dev + cargo-build-name: ${{ matrix.service }} + cargo-build-path: packages/${{ matrix.service }}/target + cargo-lock-path: packages/${{ matrix.service }}/Cargo.lock - name: Install Chrome run: | @@ -76,13 +53,13 @@ jobs: PROTOC: "/usr/bin/protoc" KEYCLOAK_DB__USER: "test" KEYCLOAK_DB__PASSWORD: "test" - KEYCLOAK_DB__HOST: "test" + KEYCLOAK_DB__HOST: "127.0.0.1" KEYCLOAK_DB__PORT: "3322" KEYCLOAK_DB__DBNAME: "test" KEYCLOAK_DB__MANAGER__RECYCLING_METHOD: "Verified" HASURA_DB__USER: "test" HASURA_DB__PASSWORD: "test" - HASURA_DB__HOST: "test" + HASURA_DB__HOST: "127.0.0.1" HASURA_DB__PORT: "3322" HASURA_DB__DBNAME: "test" HASURA_DB__MANAGER__RECYCLING_METHOD: "Verified" @@ -92,6 +69,67 @@ jobs: CARGO_BUILD_JOBS: ${{ matrix.build_jobs || 4 }} run: cd packages/${{ matrix.service }} && cargo test ${{ matrix.extra }} + # Windmill has PostgreSQL-backed tests, so it uses a dedicated job with an + # unconditional service container instead of matrix-dependent service YAML. + run-windmill-tests: + name: Run Windmill tests + runs-on: ubuntu-24.04 + timeout-minutes: 35 + env: + RUST_BACKTRACE: "full" + PROTOC: "/usr/bin/protoc" + KEYCLOAK_DB__USER: "test" + KEYCLOAK_DB__PASSWORD: "test" + KEYCLOAK_DB__HOST: "127.0.0.1" + KEYCLOAK_DB__PORT: "3322" + KEYCLOAK_DB__DBNAME: "test" + KEYCLOAK_DB__MANAGER__RECYCLING_METHOD: "Verified" + HASURA_DB__USER: "test" + HASURA_DB__PASSWORD: "test" + HASURA_DB__HOST: "127.0.0.1" + HASURA_DB__PORT: "3322" + HASURA_DB__DBNAME: "test" + HASURA_DB__MANAGER__RECYCLING_METHOD: "Verified" + LOW_SQL_LIMIT: "1000" + DEFAULT_SQL_LIMIT: "20" + DEFAULT_SQL_BATCH_SIZE: "1000" + CARGO_BUILD_JOBS: "2" + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: test + POSTGRES_PASSWORD: test + POSTGRES_DB: test + ports: + - 3322:5432 + options: >- + --health-cmd "pg_isready -U test -d test" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: + - name: Check out code + uses: actions/checkout@v4 + + - name: Set up Rust tests + uses: ./.github/actions/setup-rust-tests + with: + cargo-build-name: windmill + cargo-build-path: packages/windmill/target + cargo-lock-path: packages/windmill/Cargo.lock + + - name: Run Windmill tests + run: cargo test + working-directory: packages/windmill + + - name: Run PostgreSQL-backed voter-channel regression + run: >- + cargo test + services::cast_votes::tests::voters_by_channel_defaults_legacy_votes_and_uses_latest_valid_revote + -- --ignored --exact + working-directory: packages/windmill + # =========================================================================== # JOB: run-frontend-tests # =========================================================================== @@ -103,7 +141,15 @@ jobs: timeout-minutes: 20 strategy: matrix: - package: [ui-core, ui-essentials, keycloak-extensions/sequent-theme] + include: + - package: ui-core + test_args: --runInBand + - package: ui-essentials + test_args: --runInBand + - package: admin-portal + test_args: --runInBand + - package: keycloak-extensions/sequent-theme + test_args: "" fail-fast: false steps: - name: Check out code @@ -121,7 +167,7 @@ jobs: working-directory: packages - name: Run ${{ matrix.package }} tests - run: yarn --cwd ./${{ matrix.package }} test + run: yarn --cwd ./${{ matrix.package }} test ${{ matrix.test_args }} working-directory: packages # =========================================================================== @@ -132,7 +178,7 @@ jobs: # =========================================================================== notify-on-failure: name: Notify on failure - needs: [run-tests, run-frontend-tests] + needs: [run-tests, run-windmill-tests, run-frontend-tests] if: failure() runs-on: ubuntu-24.04 steps: diff --git a/docs/docusaurus/docs/07-developers/08-windmill/02-tests.md b/docs/docusaurus/docs/07-developers/08-windmill/02-tests.md index cfbfadc6260..4264aea206f 100644 --- a/docs/docusaurus/docs/07-developers/08-windmill/02-tests.md +++ b/docs/docusaurus/docs/07-developers/08-windmill/02-tests.md @@ -60,6 +60,22 @@ cd /workspaces/step/packages/windmill && \ --- +## PostgreSQL-backed voter-channel test + +`voters_by_channel_defaults_legacy_votes_and_uses_latest_valid_revote` is +ignored by the default `cargo test` command because it requires PostgreSQL and +the `HASURA_DB__*` connection variables. GitHub Actions runs it in the dedicated +Windmill PostgreSQL job. In a configured dev container, run it with: + +```bash +cd /workspaces/step/packages/windmill && \ + cargo test \ + services::cast_votes::tests::voters_by_channel_defaults_legacy_votes_and_uses_latest_valid_revote \ + -- --ignored --exact +``` + +--- + ## Memory Tests ### What they measure diff --git a/hasura/metadata/actions.graphql b/hasura/metadata/actions.graphql index e2baa5b0e37..356acd40f8f 100644 --- a/hasura/metadata/actions.graphql +++ b/hasura/metadata/actions.graphql @@ -1134,11 +1134,18 @@ type GetPrivateKeyOutput { type CastVotesPerDay { day: date! + channel: String! day_count: Int! } +type VotersByChannel { + channel: String! + count: Int! +} + type ElectionEventStatsOutput { total_distinct_voters: Int! + voters_by_channel: [VotersByChannel!]! total_areas: Int! total_eligible_voters: Int! total_elections: Int! @@ -1147,6 +1154,7 @@ type ElectionEventStatsOutput { type ElectionStatsOutput { total_distinct_voters: Int! + voters_by_channel: [VotersByChannel!]! total_areas: Int! votes_per_day: [CastVotesPerDay]! } diff --git a/hasura/metadata/actions.yaml b/hasura/metadata/actions.yaml index 488644be136..7d80a380182 100644 --- a/hasura/metadata/actions.yaml +++ b/hasura/metadata/actions.yaml @@ -1722,6 +1722,7 @@ custom_types: - name: CreateKeysCeremonyOutput - name: GetPrivateKeyOutput - name: CastVotesPerDay + - name: VotersByChannel - name: ElectionEventStatsOutput - name: ElectionStatsOutput - name: CheckPrivateKeyOutput diff --git a/packages/admin-portal/graphql.schema.json b/packages/admin-portal/graphql.schema.json index 389cef0a4fb..e0d038a1779 100644 --- a/packages/admin-portal/graphql.schema.json +++ b/packages/admin-portal/graphql.schema.json @@ -662,6 +662,22 @@ "description": null, "isOneOf": null, "fields": [ + { + "name": "channel", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "day", "description": null, @@ -2258,6 +2274,30 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "voters_by_channel", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "VotersByChannel", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "votes_per_day", "description": null, @@ -2414,6 +2454,30 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "voters_by_channel", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "VotersByChannel", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "votes_per_day", "description": null, @@ -9227,6 +9291,50 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "OBJECT", + "name": "VotersByChannel", + "description": null, + "isOneOf": null, + "fields": [ + { + "name": "channel", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "count", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, { "kind": "OBJECT", "name": "VotesInfo", @@ -180026,4 +180134,4 @@ } ] } -} \ No newline at end of file +} diff --git a/packages/admin-portal/rust/sequent-core-0.1.0.tgz b/packages/admin-portal/rust/sequent-core-0.1.0.tgz index 1912c3461cb..ee765982079 100644 Binary files a/packages/admin-portal/rust/sequent-core-0.1.0.tgz and b/packages/admin-portal/rust/sequent-core-0.1.0.tgz differ diff --git a/packages/admin-portal/src/components/dashboard/charts/VotersByChannel.tsx b/packages/admin-portal/src/components/dashboard/charts/VotersByChannel.tsx index 9c289892149..44a9257b755 100644 --- a/packages/admin-portal/src/components/dashboard/charts/VotersByChannel.tsx +++ b/packages/admin-portal/src/components/dashboard/charts/VotersByChannel.tsx @@ -6,18 +6,7 @@ import React from "react" import Chart, {Props} from "react-apexcharts" import CardChart from "./Charts" import {useTranslation} from "react-i18next" - -export enum VotingChanel { - Online = "Online", - Paper = "Paper", - Telephone = "Telephone", - Postal = "Postal", -} - -export interface TotalVotersRow { - count: number - channel: VotingChanel -} +import {TotalVotersRow} from "./votersByChannelData" interface VotersByChannelProps { data: TotalVotersRow[] @@ -27,10 +16,13 @@ interface VotersByChannelProps { export const VotersByChannel: React.FC = ({data, width, height}) => { const {t} = useTranslation() + const visibleData = data.filter(({count}) => count > 0) const state: Props = { options: { - labels: data.map((item) => item.channel.toString()), + labels: visibleData.map((item) => + String(t(`common.channel.${item.channel.toLowerCase()}`)) + ), plotOptions: { pie: { donut: { @@ -45,7 +37,7 @@ export const VotersByChannel: React.FC = ({data, width, he }, }, }, - series: data.map((item) => item.count), + series: visibleData.map((item) => item.count), } return ( diff --git a/packages/admin-portal/src/components/dashboard/charts/VotesPerDay.tsx b/packages/admin-portal/src/components/dashboard/charts/VotesPerDay.tsx index 1dd5587d745..cae945dd100 100644 --- a/packages/admin-portal/src/components/dashboard/charts/VotesPerDay.tsx +++ b/packages/admin-portal/src/components/dashboard/charts/VotesPerDay.tsx @@ -8,6 +8,7 @@ import CardChart, {getWeekLegend} from "./Charts" import {CastVotesPerDay} from "@/gql/graphql" import {useTranslation} from "react-i18next" import {CircularProgress} from "@mui/material" +import {toVotesPerDayChartData} from "./votesPerDayData" export interface VotersPerDayProps { data: CastVotesPerDay[] | null @@ -23,21 +24,24 @@ export const VotesPerDay: React.FC = ({data, width, height, e return } + const chartData = toVotesPerDayChartData(data) const state: Props = { options: { chart: { id: "barchart-votes", + stacked: true, }, xaxis: { categories: getWeekLegend(endDate), }, - }, - series: [ - { - name: "series-1", - data: data.map((item) => item.day_count), + legend: { + showForZeroSeries: false, }, - ], + }, + series: chartData.series.map(({channel, data: channelData}) => ({ + name: String(t(`common.channel.${channel.toLowerCase()}`)), + data: channelData, + })), } return ( diff --git a/packages/admin-portal/src/components/dashboard/charts/votersByChannelData.test.ts b/packages/admin-portal/src/components/dashboard/charts/votersByChannelData.test.ts new file mode 100644 index 00000000000..622d7c0e692 --- /dev/null +++ b/packages/admin-portal/src/components/dashboard/charts/votersByChannelData.test.ts @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: 2025 Sequent Tech Inc +// +// SPDX-License-Identifier: AGPL-3.0-only + +import {CastVoteChannel, toVotersByChannelRows} from "./votersByChannelData" + +describe("toVotersByChannelRows", () => { + it("maps only channels with voters", () => { + expect( + toVotersByChannelRows([ + {channel: "ONLINE", count: 7}, + {channel: "TELEPHONE", count: 3}, + ]) + ).toEqual([ + {channel: CastVoteChannel.ONLINE, count: 7}, + {channel: CastVoteChannel.TELEPHONE, count: 3}, + ]) + }) + + it("does not expose unsupported channels", () => { + expect( + toVotersByChannelRows([ + {channel: "ONLINE", count: 2}, + {channel: "PAPER", count: 3}, + {channel: "FUTURE_CHANNEL", count: 4}, + ]) + ).toEqual([{channel: CastVoteChannel.ONLINE, count: 2}]) + }) + + it("returns no legend rows when no channel has voters", () => { + expect(toVotersByChannelRows([])).toEqual([]) + }) +}) diff --git a/packages/admin-portal/src/components/dashboard/charts/votersByChannelData.ts b/packages/admin-portal/src/components/dashboard/charts/votersByChannelData.ts new file mode 100644 index 00000000000..87ffc2023fd --- /dev/null +++ b/packages/admin-portal/src/components/dashboard/charts/votersByChannelData.ts @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: 2025 Sequent Tech Inc +// +// SPDX-License-Identifier: AGPL-3.0-only + +export interface PersistedVotersByChannel { + channel: string + count: number +} + +export enum CastVoteChannel { + ONLINE = "ONLINE", + KIOSK = "KIOSK", + EARLY_VOTING = "EARLY_VOTING", + TELEPHONE = "TELEPHONE", +} + +export interface TotalVotersRow { + count: number + channel: CastVoteChannel +} + +export const toVotersByChannelRows = ( + data: ReadonlyArray | null | undefined +): TotalVotersRow[] => { + const counts = new Map(data?.map(({channel, count}) => [channel, count]) ?? []) + + return Object.values(CastVoteChannel) + .map((channel) => ({channel, count: counts.get(channel) ?? 0})) + .filter(({count}) => count > 0) +} diff --git a/packages/admin-portal/src/components/dashboard/charts/votesPerDayData.test.ts b/packages/admin-portal/src/components/dashboard/charts/votesPerDayData.test.ts new file mode 100644 index 00000000000..1c5dda01508 --- /dev/null +++ b/packages/admin-portal/src/components/dashboard/charts/votesPerDayData.test.ts @@ -0,0 +1,35 @@ +// SPDX-FileCopyrightText: 2025 Sequent Tech Inc +// +// SPDX-License-Identifier: AGPL-3.0-only + +import {CastVoteChannel} from "./votersByChannelData" +import {toVotesPerDayChartData} from "./votesPerDayData" + +describe("toVotesPerDayChartData", () => { + it("builds stacked series and omits channels without votes", () => { + expect( + toVotesPerDayChartData([ + {day: "2026-07-27", channel: "ONLINE", day_count: 0}, + {day: "2026-07-27", channel: "KIOSK", day_count: 2}, + {day: "2026-07-28", channel: "ONLINE", day_count: 3}, + {day: "2026-07-28", channel: "KIOSK", day_count: 1}, + {day: "2026-07-28", channel: "FUTURE_CHANNEL", day_count: 4}, + ]) + ).toEqual({ + days: ["2026-07-27", "2026-07-28"], + series: [ + {channel: CastVoteChannel.ONLINE, data: [0, 3]}, + {channel: CastVoteChannel.KIOSK, data: [2, 1]}, + ], + }) + }) + + it("returns no series when there are no votes", () => { + expect( + toVotesPerDayChartData([ + {day: "2026-07-27", channel: "ONLINE", day_count: 0}, + {day: "2026-07-28", channel: "ONLINE", day_count: 0}, + ]) + ).toEqual({days: ["2026-07-27", "2026-07-28"], series: []}) + }) +}) diff --git a/packages/admin-portal/src/components/dashboard/charts/votesPerDayData.ts b/packages/admin-portal/src/components/dashboard/charts/votesPerDayData.ts new file mode 100644 index 00000000000..f0c98766063 --- /dev/null +++ b/packages/admin-portal/src/components/dashboard/charts/votesPerDayData.ts @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: 2025 Sequent Tech Inc +// +// SPDX-License-Identifier: AGPL-3.0-only + +import {CastVoteChannel} from "./votersByChannelData" + +export interface PersistedVotesPerDay { + day: string + day_count: number + channel: string +} + +export interface VotesPerDaySeries { + channel: CastVoteChannel + data: number[] +} + +export interface VotesPerDayChartData { + days: string[] + series: VotesPerDaySeries[] +} + +export const toVotesPerDayChartData = ( + data: ReadonlyArray +): VotesPerDayChartData => { + const days = Array.from(new Set(data.map(({day}) => String(day)))).sort() + const counts = new Map() + + for (const {day, channel, day_count} of data) { + const key = `${String(day)}:${channel}` + counts.set(key, (counts.get(key) ?? 0) + day_count) + } + + const series = Object.values(CastVoteChannel) + .map((channel) => ({ + channel, + data: days.map((day) => counts.get(`${day}:${channel}`) ?? 0), + })) + .filter(({data: channelData}) => channelData.some((count) => count > 0)) + + return {days, series} +} diff --git a/packages/admin-portal/src/components/dashboard/election-event/Dashboard.tsx b/packages/admin-portal/src/components/dashboard/election-event/Dashboard.tsx index 14b96fba60e..d5762c708cf 100644 --- a/packages/admin-portal/src/components/dashboard/election-event/Dashboard.tsx +++ b/packages/admin-portal/src/components/dashboard/election-event/Dashboard.tsx @@ -11,7 +11,8 @@ import {Stats} from "./Stats" import {useTranslation} from "react-i18next" import {daysBefore, formatDate, getToday} from "../charts/Charts" import {VotesPerDay} from "../charts/VotesPerDay" -import {VotingChanel, VotersByChannel} from "../charts/VotersByChannel" +import {VotersByChannel} from "../charts/VotersByChannel" +import {toVotersByChannelRows} from "../charts/votersByChannelData" import {useTenantStore} from "@/providers/TenantContextProvider" import { CastVotesPerDay, @@ -233,24 +234,7 @@ const DashboardElectionEvent: React.FC = (props) => endDate={endDate} /> diff --git a/packages/admin-portal/src/components/dashboard/election/Dashboard.tsx b/packages/admin-portal/src/components/dashboard/election/Dashboard.tsx index a779d1bce27..fe812fb4593 100644 --- a/packages/admin-portal/src/components/dashboard/election/Dashboard.tsx +++ b/packages/admin-portal/src/components/dashboard/election/Dashboard.tsx @@ -8,7 +8,8 @@ import {styled} from "@mui/material/styles" import {Stats} from "./Stats" import {VotesPerDay} from "../charts/VotesPerDay" import {daysBefore, formatDate, getToday} from "../charts/Charts" -import {VotersByChannel, VotingChanel} from "../charts/VotersByChannel" +import {VotersByChannel} from "../charts/VotersByChannel" +import {toVotersByChannelRows} from "../charts/votersByChannelData" import {useRecordContext} from "react-admin" import {CastVotesPerDay, GetElectionStatsQuery, Sequent_Backend_Election} from "@/gql/graphql" import {SettingsContext} from "@/providers/SettingsContextProvider" @@ -93,24 +94,7 @@ export default function DashboardElection() { endDate={endDate} /> diff --git a/packages/admin-portal/src/gql/gql.ts b/packages/admin-portal/src/gql/gql.ts index 0e93ea678cb..525814f15fa 100644 --- a/packages/admin-portal/src/gql/gql.ts +++ b/packages/admin-portal/src/gql/gql.ts @@ -70,12 +70,12 @@ type Documents = { "\n query sequent_backend_contest_extended(\n $electionEventId: uuid!\n $contestId: uuid!\n $tenantId: uuid!\n ) {\n sequent_backend_area_contest(\n where: {\n _and: {\n election_event_id: {_eq: $electionEventId}\n contest_id: {_eq: $contestId}\n tenant_id: {_eq: $tenantId}\n }\n }\n ) {\n area {\n id\n name\n }\n }\n }\n": typeof types.Sequent_Backend_Contest_ExtendedDocument, "\n query GetDocument($id: uuid, $tenantId: uuid) {\n sequent_backend_document(where: {_and: {tenant_id: {_eq: $tenantId}, id: {_eq: $id}}}) {\n name\n }\n }\n": typeof types.GetDocumentDocument, "\n query GetDocumentByName($name: String!, $tenantId: uuid!) {\n sequent_backend_document(where: {_and: {name: {_eq: $name}, tenant_id: {_eq: $tenantId}}}) {\n id\n }\n }\n": typeof types.GetDocumentByNameDocument, - "\n query GetElectionEventStats(\n $tenantId: uuid!\n $electionEventId: uuid!\n $startDate: String!\n $endDate: String!\n $userTimezone: String!\n ) {\n stats: getElectionEventStats(\n object: {\n election_event_id: $electionEventId\n start_date: $startDate\n end_date: $endDate\n user_timezone: $userTimezone\n }\n ) {\n total_eligible_voters\n total_distinct_voters\n total_areas\n total_elections\n votes_per_day {\n day\n day_count\n }\n }\n election_event: sequent_backend_election_event(\n where: {id: {_eq: $electionEventId}, tenant_id: {_eq: $tenantId}}\n ) {\n statistics\n }\n }\n": typeof types.GetElectionEventStatsDocument, + "\n query GetElectionEventStats(\n $tenantId: uuid!\n $electionEventId: uuid!\n $startDate: String!\n $endDate: String!\n $userTimezone: String!\n ) {\n stats: getElectionEventStats(\n object: {\n election_event_id: $electionEventId\n start_date: $startDate\n end_date: $endDate\n user_timezone: $userTimezone\n }\n ) {\n total_eligible_voters\n total_distinct_voters\n voters_by_channel {\n channel\n count\n }\n total_areas\n total_elections\n votes_per_day {\n day\n channel\n day_count\n }\n }\n election_event: sequent_backend_election_event(\n where: {id: {_eq: $electionEventId}, tenant_id: {_eq: $tenantId}}\n ) {\n statistics\n }\n }\n": typeof types.GetElectionEventStatsDocument, "\n query election_events_tree($tenantId: uuid!, $isArchived: Boolean!) {\n sequent_backend_election_event(\n where: {is_archived: {_eq: $isArchived}, _and: {tenant_id: {_eq: $tenantId}}}\n ) {\n id\n presentation\n is_archived\n }\n }\n": typeof types.Election_Events_TreeDocument, "\n query election_tree($tenantId: uuid!, $electionEventId: uuid!) {\n sequent_backend_election(\n where: {election_event_id: {_eq: $electionEventId}, _and: {tenant_id: {_eq: $tenantId}}}\n ) {\n id\n presentation\n election_event_id\n }\n }\n": typeof types.Election_TreeDocument, "\n query contest_tree($tenantId: uuid!, $electionId: uuid!) {\n sequent_backend_contest(\n where: {election_id: {_eq: $electionId}, _and: {tenant_id: {_eq: $tenantId}}}\n ) {\n id\n presentation\n election_event_id\n election_id\n }\n }\n": typeof types.Contest_TreeDocument, "\n query candidate_tree($tenantId: uuid!, $contestId: uuid!) {\n sequent_backend_candidate(\n where: {contest_id: {_eq: $contestId}, _and: {tenant_id: {_eq: $tenantId}}}\n ) {\n id\n presentation\n election_event_id\n contest_id\n }\n }\n": typeof types.Candidate_TreeDocument, - "\n query GetElectionStats(\n $tenantId: uuid!\n $electionEventId: uuid!\n $electionId: uuid!\n $startDate: String!\n $endDate: String!\n $electionAlias: String\n $userTimezone: String!\n ) {\n stats: getElectionStats(\n object: {\n election_event_id: $electionEventId\n election_id: $electionId\n start_date: $startDate\n end_date: $endDate\n user_timezone: $userTimezone\n }\n ) {\n total_distinct_voters\n total_areas\n votes_per_day {\n day\n day_count\n }\n }\n users: count_users(\n body: {\n tenant_id: $tenantId\n election_event_id: $electionEventId\n election_id: $electionId\n authorized_to_election_alias: $electionAlias\n }\n ) {\n count\n }\n election: sequent_backend_election(\n where: {\n tenant_id: {_eq: $tenantId}\n election_event_id: {_eq: $electionEventId}\n id: {_eq: $electionId}\n }\n ) {\n statistics\n }\n }\n": typeof types.GetElectionStatsDocument, + "\n query GetElectionStats(\n $tenantId: uuid!\n $electionEventId: uuid!\n $electionId: uuid!\n $startDate: String!\n $endDate: String!\n $electionAlias: String\n $userTimezone: String!\n ) {\n stats: getElectionStats(\n object: {\n election_event_id: $electionEventId\n election_id: $electionId\n start_date: $startDate\n end_date: $endDate\n user_timezone: $userTimezone\n }\n ) {\n total_distinct_voters\n voters_by_channel {\n channel\n count\n }\n total_areas\n votes_per_day {\n day\n channel\n day_count\n }\n }\n users: count_users(\n body: {\n tenant_id: $tenantId\n election_event_id: $electionEventId\n election_id: $electionId\n authorized_to_election_alias: $electionAlias\n }\n ) {\n count\n }\n election: sequent_backend_election(\n where: {\n tenant_id: {_eq: $tenantId}\n election_event_id: {_eq: $electionEventId}\n id: {_eq: $electionId}\n }\n ) {\n statistics\n }\n }\n": typeof types.GetElectionStatsDocument, "\n query GetElections {\n sequent_backend_election {\n annotations\n created_at\n description\n election_event_id\n eml\n id\n is_consolidated_ballot_encoding\n labels\n last_updated_at\n num_allowed_revotes\n presentation\n spoil_ballot_option\n status\n tenant_id\n permission_label\n initialization_report_generated\n external_id\n }\n }\n": typeof types.GetElectionsDocument, "\n query GetElectionsByExternalId($external_ids: [String!]!, $election_event_id: uuid) {\n sequent_backend_election(\n where: {\n external_id: {_in: $external_ids}\n _and: [{election_event_id: {_eq: $election_event_id}}]\n }\n ) {\n id\n external_id\n election_event_id\n presentation\n }\n }\n": typeof types.GetElectionsByExternalIdDocument, "\n query GetEventExecution($tenantId: uuid!, $scheduledEventId: uuid!) {\n sequent_backend_event_execution(\n where: {scheduled_event_id: {_eq: $scheduledEventId}, tenant_id: {_eq: $tenantId}}\n ) {\n id\n tenant_id\n election_event_id\n scheduled_event_id\n labels\n annotations\n execution_state\n execution_payload\n result_payload\n started_at\n ended_at\n }\n }\n": typeof types.GetEventExecutionDocument, @@ -196,12 +196,12 @@ const documents: Documents = { "\n query sequent_backend_contest_extended(\n $electionEventId: uuid!\n $contestId: uuid!\n $tenantId: uuid!\n ) {\n sequent_backend_area_contest(\n where: {\n _and: {\n election_event_id: {_eq: $electionEventId}\n contest_id: {_eq: $contestId}\n tenant_id: {_eq: $tenantId}\n }\n }\n ) {\n area {\n id\n name\n }\n }\n }\n": types.Sequent_Backend_Contest_ExtendedDocument, "\n query GetDocument($id: uuid, $tenantId: uuid) {\n sequent_backend_document(where: {_and: {tenant_id: {_eq: $tenantId}, id: {_eq: $id}}}) {\n name\n }\n }\n": types.GetDocumentDocument, "\n query GetDocumentByName($name: String!, $tenantId: uuid!) {\n sequent_backend_document(where: {_and: {name: {_eq: $name}, tenant_id: {_eq: $tenantId}}}) {\n id\n }\n }\n": types.GetDocumentByNameDocument, - "\n query GetElectionEventStats(\n $tenantId: uuid!\n $electionEventId: uuid!\n $startDate: String!\n $endDate: String!\n $userTimezone: String!\n ) {\n stats: getElectionEventStats(\n object: {\n election_event_id: $electionEventId\n start_date: $startDate\n end_date: $endDate\n user_timezone: $userTimezone\n }\n ) {\n total_eligible_voters\n total_distinct_voters\n total_areas\n total_elections\n votes_per_day {\n day\n day_count\n }\n }\n election_event: sequent_backend_election_event(\n where: {id: {_eq: $electionEventId}, tenant_id: {_eq: $tenantId}}\n ) {\n statistics\n }\n }\n": types.GetElectionEventStatsDocument, + "\n query GetElectionEventStats(\n $tenantId: uuid!\n $electionEventId: uuid!\n $startDate: String!\n $endDate: String!\n $userTimezone: String!\n ) {\n stats: getElectionEventStats(\n object: {\n election_event_id: $electionEventId\n start_date: $startDate\n end_date: $endDate\n user_timezone: $userTimezone\n }\n ) {\n total_eligible_voters\n total_distinct_voters\n voters_by_channel {\n channel\n count\n }\n total_areas\n total_elections\n votes_per_day {\n day\n channel\n day_count\n }\n }\n election_event: sequent_backend_election_event(\n where: {id: {_eq: $electionEventId}, tenant_id: {_eq: $tenantId}}\n ) {\n statistics\n }\n }\n": types.GetElectionEventStatsDocument, "\n query election_events_tree($tenantId: uuid!, $isArchived: Boolean!) {\n sequent_backend_election_event(\n where: {is_archived: {_eq: $isArchived}, _and: {tenant_id: {_eq: $tenantId}}}\n ) {\n id\n presentation\n is_archived\n }\n }\n": types.Election_Events_TreeDocument, "\n query election_tree($tenantId: uuid!, $electionEventId: uuid!) {\n sequent_backend_election(\n where: {election_event_id: {_eq: $electionEventId}, _and: {tenant_id: {_eq: $tenantId}}}\n ) {\n id\n presentation\n election_event_id\n }\n }\n": types.Election_TreeDocument, "\n query contest_tree($tenantId: uuid!, $electionId: uuid!) {\n sequent_backend_contest(\n where: {election_id: {_eq: $electionId}, _and: {tenant_id: {_eq: $tenantId}}}\n ) {\n id\n presentation\n election_event_id\n election_id\n }\n }\n": types.Contest_TreeDocument, "\n query candidate_tree($tenantId: uuid!, $contestId: uuid!) {\n sequent_backend_candidate(\n where: {contest_id: {_eq: $contestId}, _and: {tenant_id: {_eq: $tenantId}}}\n ) {\n id\n presentation\n election_event_id\n contest_id\n }\n }\n": types.Candidate_TreeDocument, - "\n query GetElectionStats(\n $tenantId: uuid!\n $electionEventId: uuid!\n $electionId: uuid!\n $startDate: String!\n $endDate: String!\n $electionAlias: String\n $userTimezone: String!\n ) {\n stats: getElectionStats(\n object: {\n election_event_id: $electionEventId\n election_id: $electionId\n start_date: $startDate\n end_date: $endDate\n user_timezone: $userTimezone\n }\n ) {\n total_distinct_voters\n total_areas\n votes_per_day {\n day\n day_count\n }\n }\n users: count_users(\n body: {\n tenant_id: $tenantId\n election_event_id: $electionEventId\n election_id: $electionId\n authorized_to_election_alias: $electionAlias\n }\n ) {\n count\n }\n election: sequent_backend_election(\n where: {\n tenant_id: {_eq: $tenantId}\n election_event_id: {_eq: $electionEventId}\n id: {_eq: $electionId}\n }\n ) {\n statistics\n }\n }\n": types.GetElectionStatsDocument, + "\n query GetElectionStats(\n $tenantId: uuid!\n $electionEventId: uuid!\n $electionId: uuid!\n $startDate: String!\n $endDate: String!\n $electionAlias: String\n $userTimezone: String!\n ) {\n stats: getElectionStats(\n object: {\n election_event_id: $electionEventId\n election_id: $electionId\n start_date: $startDate\n end_date: $endDate\n user_timezone: $userTimezone\n }\n ) {\n total_distinct_voters\n voters_by_channel {\n channel\n count\n }\n total_areas\n votes_per_day {\n day\n channel\n day_count\n }\n }\n users: count_users(\n body: {\n tenant_id: $tenantId\n election_event_id: $electionEventId\n election_id: $electionId\n authorized_to_election_alias: $electionAlias\n }\n ) {\n count\n }\n election: sequent_backend_election(\n where: {\n tenant_id: {_eq: $tenantId}\n election_event_id: {_eq: $electionEventId}\n id: {_eq: $electionId}\n }\n ) {\n statistics\n }\n }\n": types.GetElectionStatsDocument, "\n query GetElections {\n sequent_backend_election {\n annotations\n created_at\n description\n election_event_id\n eml\n id\n is_consolidated_ballot_encoding\n labels\n last_updated_at\n num_allowed_revotes\n presentation\n spoil_ballot_option\n status\n tenant_id\n permission_label\n initialization_report_generated\n external_id\n }\n }\n": types.GetElectionsDocument, "\n query GetElectionsByExternalId($external_ids: [String!]!, $election_event_id: uuid) {\n sequent_backend_election(\n where: {\n external_id: {_in: $external_ids}\n _and: [{election_event_id: {_eq: $election_event_id}}]\n }\n ) {\n id\n external_id\n election_event_id\n presentation\n }\n }\n": types.GetElectionsByExternalIdDocument, "\n query GetEventExecution($tenantId: uuid!, $scheduledEventId: uuid!) {\n sequent_backend_event_execution(\n where: {scheduled_event_id: {_eq: $scheduledEventId}, tenant_id: {_eq: $tenantId}}\n ) {\n id\n tenant_id\n election_event_id\n scheduled_event_id\n labels\n annotations\n execution_state\n execution_payload\n result_payload\n started_at\n ended_at\n }\n }\n": types.GetEventExecutionDocument, @@ -507,7 +507,7 @@ export function graphql(source: "\n query GetDocumentByName($name: String!, $ /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ -export function graphql(source: "\n query GetElectionEventStats(\n $tenantId: uuid!\n $electionEventId: uuid!\n $startDate: String!\n $endDate: String!\n $userTimezone: String!\n ) {\n stats: getElectionEventStats(\n object: {\n election_event_id: $electionEventId\n start_date: $startDate\n end_date: $endDate\n user_timezone: $userTimezone\n }\n ) {\n total_eligible_voters\n total_distinct_voters\n total_areas\n total_elections\n votes_per_day {\n day\n day_count\n }\n }\n election_event: sequent_backend_election_event(\n where: {id: {_eq: $electionEventId}, tenant_id: {_eq: $tenantId}}\n ) {\n statistics\n }\n }\n"): (typeof documents)["\n query GetElectionEventStats(\n $tenantId: uuid!\n $electionEventId: uuid!\n $startDate: String!\n $endDate: String!\n $userTimezone: String!\n ) {\n stats: getElectionEventStats(\n object: {\n election_event_id: $electionEventId\n start_date: $startDate\n end_date: $endDate\n user_timezone: $userTimezone\n }\n ) {\n total_eligible_voters\n total_distinct_voters\n total_areas\n total_elections\n votes_per_day {\n day\n day_count\n }\n }\n election_event: sequent_backend_election_event(\n where: {id: {_eq: $electionEventId}, tenant_id: {_eq: $tenantId}}\n ) {\n statistics\n }\n }\n"]; +export function graphql(source: "\n query GetElectionEventStats(\n $tenantId: uuid!\n $electionEventId: uuid!\n $startDate: String!\n $endDate: String!\n $userTimezone: String!\n ) {\n stats: getElectionEventStats(\n object: {\n election_event_id: $electionEventId\n start_date: $startDate\n end_date: $endDate\n user_timezone: $userTimezone\n }\n ) {\n total_eligible_voters\n total_distinct_voters\n voters_by_channel {\n channel\n count\n }\n total_areas\n total_elections\n votes_per_day {\n day\n channel\n day_count\n }\n }\n election_event: sequent_backend_election_event(\n where: {id: {_eq: $electionEventId}, tenant_id: {_eq: $tenantId}}\n ) {\n statistics\n }\n }\n"): (typeof documents)["\n query GetElectionEventStats(\n $tenantId: uuid!\n $electionEventId: uuid!\n $startDate: String!\n $endDate: String!\n $userTimezone: String!\n ) {\n stats: getElectionEventStats(\n object: {\n election_event_id: $electionEventId\n start_date: $startDate\n end_date: $endDate\n user_timezone: $userTimezone\n }\n ) {\n total_eligible_voters\n total_distinct_voters\n voters_by_channel {\n channel\n count\n }\n total_areas\n total_elections\n votes_per_day {\n day\n channel\n day_count\n }\n }\n election_event: sequent_backend_election_event(\n where: {id: {_eq: $electionEventId}, tenant_id: {_eq: $tenantId}}\n ) {\n statistics\n }\n }\n"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ @@ -527,7 +527,7 @@ export function graphql(source: "\n query candidate_tree($tenantId: uuid!, $c /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ -export function graphql(source: "\n query GetElectionStats(\n $tenantId: uuid!\n $electionEventId: uuid!\n $electionId: uuid!\n $startDate: String!\n $endDate: String!\n $electionAlias: String\n $userTimezone: String!\n ) {\n stats: getElectionStats(\n object: {\n election_event_id: $electionEventId\n election_id: $electionId\n start_date: $startDate\n end_date: $endDate\n user_timezone: $userTimezone\n }\n ) {\n total_distinct_voters\n total_areas\n votes_per_day {\n day\n day_count\n }\n }\n users: count_users(\n body: {\n tenant_id: $tenantId\n election_event_id: $electionEventId\n election_id: $electionId\n authorized_to_election_alias: $electionAlias\n }\n ) {\n count\n }\n election: sequent_backend_election(\n where: {\n tenant_id: {_eq: $tenantId}\n election_event_id: {_eq: $electionEventId}\n id: {_eq: $electionId}\n }\n ) {\n statistics\n }\n }\n"): (typeof documents)["\n query GetElectionStats(\n $tenantId: uuid!\n $electionEventId: uuid!\n $electionId: uuid!\n $startDate: String!\n $endDate: String!\n $electionAlias: String\n $userTimezone: String!\n ) {\n stats: getElectionStats(\n object: {\n election_event_id: $electionEventId\n election_id: $electionId\n start_date: $startDate\n end_date: $endDate\n user_timezone: $userTimezone\n }\n ) {\n total_distinct_voters\n total_areas\n votes_per_day {\n day\n day_count\n }\n }\n users: count_users(\n body: {\n tenant_id: $tenantId\n election_event_id: $electionEventId\n election_id: $electionId\n authorized_to_election_alias: $electionAlias\n }\n ) {\n count\n }\n election: sequent_backend_election(\n where: {\n tenant_id: {_eq: $tenantId}\n election_event_id: {_eq: $electionEventId}\n id: {_eq: $electionId}\n }\n ) {\n statistics\n }\n }\n"]; +export function graphql(source: "\n query GetElectionStats(\n $tenantId: uuid!\n $electionEventId: uuid!\n $electionId: uuid!\n $startDate: String!\n $endDate: String!\n $electionAlias: String\n $userTimezone: String!\n ) {\n stats: getElectionStats(\n object: {\n election_event_id: $electionEventId\n election_id: $electionId\n start_date: $startDate\n end_date: $endDate\n user_timezone: $userTimezone\n }\n ) {\n total_distinct_voters\n voters_by_channel {\n channel\n count\n }\n total_areas\n votes_per_day {\n day\n channel\n day_count\n }\n }\n users: count_users(\n body: {\n tenant_id: $tenantId\n election_event_id: $electionEventId\n election_id: $electionId\n authorized_to_election_alias: $electionAlias\n }\n ) {\n count\n }\n election: sequent_backend_election(\n where: {\n tenant_id: {_eq: $tenantId}\n election_event_id: {_eq: $electionEventId}\n id: {_eq: $electionId}\n }\n ) {\n statistics\n }\n }\n"): (typeof documents)["\n query GetElectionStats(\n $tenantId: uuid!\n $electionEventId: uuid!\n $electionId: uuid!\n $startDate: String!\n $endDate: String!\n $electionAlias: String\n $userTimezone: String!\n ) {\n stats: getElectionStats(\n object: {\n election_event_id: $electionEventId\n election_id: $electionId\n start_date: $startDate\n end_date: $endDate\n user_timezone: $userTimezone\n }\n ) {\n total_distinct_voters\n voters_by_channel {\n channel\n count\n }\n total_areas\n votes_per_day {\n day\n channel\n day_count\n }\n }\n users: count_users(\n body: {\n tenant_id: $tenantId\n election_event_id: $electionEventId\n election_id: $electionId\n authorized_to_election_alias: $electionAlias\n }\n ) {\n count\n }\n election: sequent_backend_election(\n where: {\n tenant_id: {_eq: $tenantId}\n election_event_id: {_eq: $electionEventId}\n id: {_eq: $electionId}\n }\n ) {\n statistics\n }\n }\n"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ @@ -781,4 +781,4 @@ export function graphql(source: string) { return (documents as any)[source] ?? {}; } -export type DocumentType> = TDocumentNode extends DocumentNode< infer TType, any> ? TType : never; \ No newline at end of file +export type DocumentType> = TDocumentNode extends DocumentNode< infer TType, any> ? TType : never; diff --git a/packages/admin-portal/src/gql/graphql.ts b/packages/admin-portal/src/gql/graphql.ts index 7b01c97dc72..b24607ea99e 100644 --- a/packages/admin-portal/src/gql/graphql.ts +++ b/packages/admin-portal/src/gql/graphql.ts @@ -96,6 +96,7 @@ export type CastVotesByIp = { export type CastVotesPerDay = { __typename?: 'CastVotesPerDay'; + channel: Scalars['String']['output']; day: Scalars['date']['output']; day_count: Scalars['Int']['output']; }; @@ -277,6 +278,7 @@ export type ElectionEventStatsOutput = { total_distinct_voters: Scalars['Int']['output']; total_elections: Scalars['Int']['output']; total_eligible_voters: Scalars['Int']['output']; + voters_by_channel: Array; votes_per_day: Array>; }; @@ -292,6 +294,7 @@ export type ElectionStatsOutput = { __typename?: 'ElectionStatsOutput'; total_areas: Scalars['Int']['output']; total_distinct_voters: Scalars['Int']['output']; + voters_by_channel: Array; votes_per_day: Array>; }; @@ -1091,6 +1094,12 @@ export type UserProfileAttribute = { validations?: Maybe; }; +export type VotersByChannel = { + __typename?: 'VotersByChannel'; + channel: Scalars['String']['output']; + count: Scalars['Int']['output']; +}; + export type VotesInfo = { __typename?: 'VotesInfo'; election_id: Scalars['String']['output']; @@ -25473,7 +25482,7 @@ export type GetElectionEventStatsQueryVariables = Exact<{ }>; -export type GetElectionEventStatsQuery = { __typename?: 'query_root', stats?: { __typename?: 'ElectionEventStatsOutput', total_eligible_voters: number, total_distinct_voters: number, total_areas: number, total_elections: number, votes_per_day: Array<{ __typename?: 'CastVotesPerDay', day: any, day_count: number } | null> } | null, election_event: Array<{ __typename?: 'sequent_backend_election_event', statistics?: any | null }> }; +export type GetElectionEventStatsQuery = { __typename?: 'query_root', stats?: { __typename?: 'ElectionEventStatsOutput', total_eligible_voters: number, total_distinct_voters: number, total_areas: number, total_elections: number, voters_by_channel: Array<{ __typename?: 'VotersByChannel', channel: string, count: number }>, votes_per_day: Array<{ __typename?: 'CastVotesPerDay', day: any, channel: string, day_count: number } | null> } | null, election_event: Array<{ __typename?: 'sequent_backend_election_event', statistics?: any | null }> }; export type Election_Events_TreeQueryVariables = Exact<{ tenantId: Scalars['uuid']['input']; @@ -25518,7 +25527,7 @@ export type GetElectionStatsQueryVariables = Exact<{ }>; -export type GetElectionStatsQuery = { __typename?: 'query_root', stats?: { __typename?: 'ElectionStatsOutput', total_distinct_voters: number, total_areas: number, votes_per_day: Array<{ __typename?: 'CastVotesPerDay', day: any, day_count: number } | null> } | null, users: { __typename?: 'CountUsersOutput', count: number }, election: Array<{ __typename?: 'sequent_backend_election', statistics?: any | null }> }; +export type GetElectionStatsQuery = { __typename?: 'query_root', stats?: { __typename?: 'ElectionStatsOutput', total_distinct_voters: number, total_areas: number, voters_by_channel: Array<{ __typename?: 'VotersByChannel', channel: string, count: number }>, votes_per_day: Array<{ __typename?: 'CastVotesPerDay', day: any, channel: string, day_count: number } | null> } | null, users: { __typename?: 'CountUsersOutput', count: number }, election: Array<{ __typename?: 'sequent_backend_election', statistics?: any | null }> }; export type GetElectionsQueryVariables = Exact<{ [key: string]: never; }>; @@ -26153,12 +26162,12 @@ export const GetCertificateAuthoritiesDocument = {"kind":"Document","definitions export const Sequent_Backend_Contest_ExtendedDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"sequent_backend_contest_extended"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"electionEventId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"uuid"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"contestId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"uuid"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"tenantId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"uuid"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"sequent_backend_area_contest"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"where"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"_and"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"election_event_id"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"_eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"electionEventId"}}}]}},{"kind":"ObjectField","name":{"kind":"Name","value":"contest_id"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"_eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"contestId"}}}]}},{"kind":"ObjectField","name":{"kind":"Name","value":"tenant_id"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"_eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"tenantId"}}}]}}]}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"area"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]} as unknown as DocumentNode; export const GetDocumentDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetDocument"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"uuid"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"tenantId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"uuid"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"sequent_backend_document"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"where"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"_and"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"tenant_id"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"_eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"tenantId"}}}]}},{"kind":"ObjectField","name":{"kind":"Name","value":"id"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"_eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}]}}]}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]} as unknown as DocumentNode; export const GetDocumentByNameDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetDocumentByName"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"name"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"tenantId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"uuid"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"sequent_backend_document"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"where"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"_and"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"name"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"_eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"name"}}}]}},{"kind":"ObjectField","name":{"kind":"Name","value":"tenant_id"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"_eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"tenantId"}}}]}}]}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]} as unknown as DocumentNode; -export const GetElectionEventStatsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetElectionEventStats"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"tenantId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"uuid"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"electionEventId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"uuid"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"startDate"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"endDate"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"userTimezone"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","alias":{"kind":"Name","value":"stats"},"name":{"kind":"Name","value":"getElectionEventStats"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"object"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"election_event_id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"electionEventId"}}},{"kind":"ObjectField","name":{"kind":"Name","value":"start_date"},"value":{"kind":"Variable","name":{"kind":"Name","value":"startDate"}}},{"kind":"ObjectField","name":{"kind":"Name","value":"end_date"},"value":{"kind":"Variable","name":{"kind":"Name","value":"endDate"}}},{"kind":"ObjectField","name":{"kind":"Name","value":"user_timezone"},"value":{"kind":"Variable","name":{"kind":"Name","value":"userTimezone"}}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"total_eligible_voters"}},{"kind":"Field","name":{"kind":"Name","value":"total_distinct_voters"}},{"kind":"Field","name":{"kind":"Name","value":"total_areas"}},{"kind":"Field","name":{"kind":"Name","value":"total_elections"}},{"kind":"Field","name":{"kind":"Name","value":"votes_per_day"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"day"}},{"kind":"Field","name":{"kind":"Name","value":"day_count"}}]}}]}},{"kind":"Field","alias":{"kind":"Name","value":"election_event"},"name":{"kind":"Name","value":"sequent_backend_election_event"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"where"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"id"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"_eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"electionEventId"}}}]}},{"kind":"ObjectField","name":{"kind":"Name","value":"tenant_id"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"_eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"tenantId"}}}]}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"statistics"}}]}}]}}]} as unknown as DocumentNode; +export const GetElectionEventStatsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetElectionEventStats"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"tenantId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"uuid"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"electionEventId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"uuid"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"startDate"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"endDate"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"userTimezone"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","alias":{"kind":"Name","value":"stats"},"name":{"kind":"Name","value":"getElectionEventStats"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"object"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"election_event_id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"electionEventId"}}},{"kind":"ObjectField","name":{"kind":"Name","value":"start_date"},"value":{"kind":"Variable","name":{"kind":"Name","value":"startDate"}}},{"kind":"ObjectField","name":{"kind":"Name","value":"end_date"},"value":{"kind":"Variable","name":{"kind":"Name","value":"endDate"}}},{"kind":"ObjectField","name":{"kind":"Name","value":"user_timezone"},"value":{"kind":"Variable","name":{"kind":"Name","value":"userTimezone"}}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"total_eligible_voters"}},{"kind":"Field","name":{"kind":"Name","value":"total_distinct_voters"}},{"kind":"Field","name":{"kind":"Name","value":"voters_by_channel"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"channel"}},{"kind":"Field","name":{"kind":"Name","value":"count"}}]}},{"kind":"Field","name":{"kind":"Name","value":"total_areas"}},{"kind":"Field","name":{"kind":"Name","value":"total_elections"}},{"kind":"Field","name":{"kind":"Name","value":"votes_per_day"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"day"}},{"kind":"Field","name":{"kind":"Name","value":"channel"}},{"kind":"Field","name":{"kind":"Name","value":"day_count"}}]}}]}},{"kind":"Field","alias":{"kind":"Name","value":"election_event"},"name":{"kind":"Name","value":"sequent_backend_election_event"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"where"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"id"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"_eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"electionEventId"}}}]}},{"kind":"ObjectField","name":{"kind":"Name","value":"tenant_id"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"_eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"tenantId"}}}]}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"statistics"}}]}}]}}]} as unknown as DocumentNode; export const Election_Events_TreeDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"election_events_tree"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"tenantId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"uuid"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"isArchived"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"sequent_backend_election_event"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"where"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"is_archived"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"_eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"isArchived"}}}]}},{"kind":"ObjectField","name":{"kind":"Name","value":"_and"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"tenant_id"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"_eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"tenantId"}}}]}}]}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"presentation"}},{"kind":"Field","name":{"kind":"Name","value":"is_archived"}}]}}]}}]} as unknown as DocumentNode; export const Election_TreeDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"election_tree"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"tenantId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"uuid"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"electionEventId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"uuid"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"sequent_backend_election"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"where"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"election_event_id"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"_eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"electionEventId"}}}]}},{"kind":"ObjectField","name":{"kind":"Name","value":"_and"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"tenant_id"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"_eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"tenantId"}}}]}}]}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"presentation"}},{"kind":"Field","name":{"kind":"Name","value":"election_event_id"}}]}}]}}]} as unknown as DocumentNode; export const Contest_TreeDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"contest_tree"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"tenantId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"uuid"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"electionId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"uuid"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"sequent_backend_contest"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"where"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"election_id"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"_eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"electionId"}}}]}},{"kind":"ObjectField","name":{"kind":"Name","value":"_and"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"tenant_id"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"_eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"tenantId"}}}]}}]}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"presentation"}},{"kind":"Field","name":{"kind":"Name","value":"election_event_id"}},{"kind":"Field","name":{"kind":"Name","value":"election_id"}}]}}]}}]} as unknown as DocumentNode; export const Candidate_TreeDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"candidate_tree"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"tenantId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"uuid"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"contestId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"uuid"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"sequent_backend_candidate"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"where"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"contest_id"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"_eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"contestId"}}}]}},{"kind":"ObjectField","name":{"kind":"Name","value":"_and"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"tenant_id"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"_eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"tenantId"}}}]}}]}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"presentation"}},{"kind":"Field","name":{"kind":"Name","value":"election_event_id"}},{"kind":"Field","name":{"kind":"Name","value":"contest_id"}}]}}]}}]} as unknown as DocumentNode; -export const GetElectionStatsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetElectionStats"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"tenantId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"uuid"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"electionEventId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"uuid"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"electionId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"uuid"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"startDate"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"endDate"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"electionAlias"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"userTimezone"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","alias":{"kind":"Name","value":"stats"},"name":{"kind":"Name","value":"getElectionStats"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"object"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"election_event_id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"electionEventId"}}},{"kind":"ObjectField","name":{"kind":"Name","value":"election_id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"electionId"}}},{"kind":"ObjectField","name":{"kind":"Name","value":"start_date"},"value":{"kind":"Variable","name":{"kind":"Name","value":"startDate"}}},{"kind":"ObjectField","name":{"kind":"Name","value":"end_date"},"value":{"kind":"Variable","name":{"kind":"Name","value":"endDate"}}},{"kind":"ObjectField","name":{"kind":"Name","value":"user_timezone"},"value":{"kind":"Variable","name":{"kind":"Name","value":"userTimezone"}}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"total_distinct_voters"}},{"kind":"Field","name":{"kind":"Name","value":"total_areas"}},{"kind":"Field","name":{"kind":"Name","value":"votes_per_day"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"day"}},{"kind":"Field","name":{"kind":"Name","value":"day_count"}}]}}]}},{"kind":"Field","alias":{"kind":"Name","value":"users"},"name":{"kind":"Name","value":"count_users"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"body"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"tenant_id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"tenantId"}}},{"kind":"ObjectField","name":{"kind":"Name","value":"election_event_id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"electionEventId"}}},{"kind":"ObjectField","name":{"kind":"Name","value":"election_id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"electionId"}}},{"kind":"ObjectField","name":{"kind":"Name","value":"authorized_to_election_alias"},"value":{"kind":"Variable","name":{"kind":"Name","value":"electionAlias"}}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"count"}}]}},{"kind":"Field","alias":{"kind":"Name","value":"election"},"name":{"kind":"Name","value":"sequent_backend_election"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"where"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"tenant_id"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"_eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"tenantId"}}}]}},{"kind":"ObjectField","name":{"kind":"Name","value":"election_event_id"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"_eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"electionEventId"}}}]}},{"kind":"ObjectField","name":{"kind":"Name","value":"id"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"_eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"electionId"}}}]}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"statistics"}}]}}]}}]} as unknown as DocumentNode; +export const GetElectionStatsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetElectionStats"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"tenantId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"uuid"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"electionEventId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"uuid"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"electionId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"uuid"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"startDate"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"endDate"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"electionAlias"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"userTimezone"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","alias":{"kind":"Name","value":"stats"},"name":{"kind":"Name","value":"getElectionStats"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"object"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"election_event_id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"electionEventId"}}},{"kind":"ObjectField","name":{"kind":"Name","value":"election_id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"electionId"}}},{"kind":"ObjectField","name":{"kind":"Name","value":"start_date"},"value":{"kind":"Variable","name":{"kind":"Name","value":"startDate"}}},{"kind":"ObjectField","name":{"kind":"Name","value":"end_date"},"value":{"kind":"Variable","name":{"kind":"Name","value":"endDate"}}},{"kind":"ObjectField","name":{"kind":"Name","value":"user_timezone"},"value":{"kind":"Variable","name":{"kind":"Name","value":"userTimezone"}}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"total_distinct_voters"}},{"kind":"Field","name":{"kind":"Name","value":"voters_by_channel"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"channel"}},{"kind":"Field","name":{"kind":"Name","value":"count"}}]}},{"kind":"Field","name":{"kind":"Name","value":"total_areas"}},{"kind":"Field","name":{"kind":"Name","value":"votes_per_day"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"day"}},{"kind":"Field","name":{"kind":"Name","value":"channel"}},{"kind":"Field","name":{"kind":"Name","value":"day_count"}}]}}]}},{"kind":"Field","alias":{"kind":"Name","value":"users"},"name":{"kind":"Name","value":"count_users"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"body"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"tenant_id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"tenantId"}}},{"kind":"ObjectField","name":{"kind":"Name","value":"election_event_id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"electionEventId"}}},{"kind":"ObjectField","name":{"kind":"Name","value":"election_id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"electionId"}}},{"kind":"ObjectField","name":{"kind":"Name","value":"authorized_to_election_alias"},"value":{"kind":"Variable","name":{"kind":"Name","value":"electionAlias"}}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"count"}}]}},{"kind":"Field","alias":{"kind":"Name","value":"election"},"name":{"kind":"Name","value":"sequent_backend_election"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"where"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"tenant_id"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"_eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"tenantId"}}}]}},{"kind":"ObjectField","name":{"kind":"Name","value":"election_event_id"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"_eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"electionEventId"}}}]}},{"kind":"ObjectField","name":{"kind":"Name","value":"id"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"_eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"electionId"}}}]}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"statistics"}}]}}]}}]} as unknown as DocumentNode; export const GetElectionsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetElections"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"sequent_backend_election"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"annotations"}},{"kind":"Field","name":{"kind":"Name","value":"created_at"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"election_event_id"}},{"kind":"Field","name":{"kind":"Name","value":"eml"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"is_consolidated_ballot_encoding"}},{"kind":"Field","name":{"kind":"Name","value":"labels"}},{"kind":"Field","name":{"kind":"Name","value":"last_updated_at"}},{"kind":"Field","name":{"kind":"Name","value":"num_allowed_revotes"}},{"kind":"Field","name":{"kind":"Name","value":"presentation"}},{"kind":"Field","name":{"kind":"Name","value":"spoil_ballot_option"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"tenant_id"}},{"kind":"Field","name":{"kind":"Name","value":"permission_label"}},{"kind":"Field","name":{"kind":"Name","value":"initialization_report_generated"}},{"kind":"Field","name":{"kind":"Name","value":"external_id"}}]}}]}}]} as unknown as DocumentNode; export const GetElectionsByExternalIdDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetElectionsByExternalId"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"external_ids"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"election_event_id"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"uuid"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"sequent_backend_election"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"where"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"external_id"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"_in"},"value":{"kind":"Variable","name":{"kind":"Name","value":"external_ids"}}}]}},{"kind":"ObjectField","name":{"kind":"Name","value":"_and"},"value":{"kind":"ListValue","values":[{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"election_event_id"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"_eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"election_event_id"}}}]}}]}]}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"external_id"}},{"kind":"Field","name":{"kind":"Name","value":"election_event_id"}},{"kind":"Field","name":{"kind":"Name","value":"presentation"}}]}}]}}]} as unknown as DocumentNode; export const GetEventExecutionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetEventExecution"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"tenantId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"uuid"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"scheduledEventId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"uuid"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"sequent_backend_event_execution"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"where"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"scheduled_event_id"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"_eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"scheduledEventId"}}}]}},{"kind":"ObjectField","name":{"kind":"Name","value":"tenant_id"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"_eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"tenantId"}}}]}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"tenant_id"}},{"kind":"Field","name":{"kind":"Name","value":"election_event_id"}},{"kind":"Field","name":{"kind":"Name","value":"scheduled_event_id"}},{"kind":"Field","name":{"kind":"Name","value":"labels"}},{"kind":"Field","name":{"kind":"Name","value":"annotations"}},{"kind":"Field","name":{"kind":"Name","value":"execution_state"}},{"kind":"Field","name":{"kind":"Name","value":"execution_payload"}},{"kind":"Field","name":{"kind":"Name","value":"result_payload"}},{"kind":"Field","name":{"kind":"Name","value":"started_at"}},{"kind":"Field","name":{"kind":"Name","value":"ended_at"}}]}}]}}]} as unknown as DocumentNode; @@ -26220,4 +26229,4 @@ export const UpsertAreaDocument = {"kind":"Document","definitions":[{"kind":"Ope export const UpsertAreasDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpsertAreas"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"electionEventId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"documentId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"upsert_areas"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"election_event_id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"electionEventId"}}},{"kind":"Argument","name":{"kind":"Name","value":"document_id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"documentId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]} as unknown as DocumentNode; export const CreateNewTallySheetDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateNewTallySheet"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"electionEventId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"channel"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"content"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"jsonb"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"contestId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"areaId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"create_new_tally_sheet"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"election_event_id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"electionEventId"}}},{"kind":"Argument","name":{"kind":"Name","value":"channel"},"value":{"kind":"Variable","name":{"kind":"Name","value":"channel"}}},{"kind":"Argument","name":{"kind":"Name","value":"content"},"value":{"kind":"Variable","name":{"kind":"Name","value":"content"}}},{"kind":"Argument","name":{"kind":"Name","value":"contest_id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"contestId"}}},{"kind":"Argument","name":{"kind":"Name","value":"area_id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"areaId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"tenant_id"}},{"kind":"Field","name":{"kind":"Name","value":"election_event_id"}},{"kind":"Field","name":{"kind":"Name","value":"election_id"}},{"kind":"Field","name":{"kind":"Name","value":"contest_id"}},{"kind":"Field","name":{"kind":"Name","value":"area_id"}},{"kind":"Field","name":{"kind":"Name","value":"created_at"}},{"kind":"Field","name":{"kind":"Name","value":"last_updated_at"}},{"kind":"Field","name":{"kind":"Name","value":"labels"}},{"kind":"Field","name":{"kind":"Name","value":"annotations"}},{"kind":"Field","name":{"kind":"Name","value":"reviewed_at"}},{"kind":"Field","name":{"kind":"Name","value":"reviewed_by_user_id"}},{"kind":"Field","name":{"kind":"Name","value":"content"}},{"kind":"Field","name":{"kind":"Name","value":"channel"}},{"kind":"Field","name":{"kind":"Name","value":"deleted_at"}},{"kind":"Field","name":{"kind":"Name","value":"created_by_user_id"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"version"}}]}}]}}]} as unknown as DocumentNode; export const LimitAccessByCountriesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"limitAccessByCountries"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"votingCountries"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"enrollCountries"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"limit_access_by_countries"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"voting_countries"},"value":{"kind":"Variable","name":{"kind":"Name","value":"votingCountries"}}},{"kind":"Argument","name":{"kind":"Name","value":"enroll_countries"},"value":{"kind":"Variable","name":{"kind":"Name","value":"enrollCountries"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}}]}}]}}]} as unknown as DocumentNode; -export const ReviewTallySheetDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ReviewTallySheet"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"electionEventId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"tallySheetId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"newStatus"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"review_tally_sheet"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"election_event_id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"electionEventId"}}},{"kind":"Argument","name":{"kind":"Name","value":"tally_sheet_id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"tallySheetId"}}},{"kind":"Argument","name":{"kind":"Name","value":"new_status"},"value":{"kind":"Variable","name":{"kind":"Name","value":"newStatus"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"tenant_id"}},{"kind":"Field","name":{"kind":"Name","value":"election_event_id"}},{"kind":"Field","name":{"kind":"Name","value":"election_id"}},{"kind":"Field","name":{"kind":"Name","value":"contest_id"}},{"kind":"Field","name":{"kind":"Name","value":"area_id"}},{"kind":"Field","name":{"kind":"Name","value":"created_at"}},{"kind":"Field","name":{"kind":"Name","value":"last_updated_at"}},{"kind":"Field","name":{"kind":"Name","value":"labels"}},{"kind":"Field","name":{"kind":"Name","value":"annotations"}},{"kind":"Field","name":{"kind":"Name","value":"reviewed_at"}},{"kind":"Field","name":{"kind":"Name","value":"reviewed_by_user_id"}},{"kind":"Field","name":{"kind":"Name","value":"content"}},{"kind":"Field","name":{"kind":"Name","value":"channel"}},{"kind":"Field","name":{"kind":"Name","value":"deleted_at"}},{"kind":"Field","name":{"kind":"Name","value":"created_by_user_id"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"version"}}]}}]}}]} as unknown as DocumentNode; \ No newline at end of file +export const ReviewTallySheetDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ReviewTallySheet"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"electionEventId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"tallySheetId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"newStatus"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"review_tally_sheet"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"election_event_id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"electionEventId"}}},{"kind":"Argument","name":{"kind":"Name","value":"tally_sheet_id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"tallySheetId"}}},{"kind":"Argument","name":{"kind":"Name","value":"new_status"},"value":{"kind":"Variable","name":{"kind":"Name","value":"newStatus"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"tenant_id"}},{"kind":"Field","name":{"kind":"Name","value":"election_event_id"}},{"kind":"Field","name":{"kind":"Name","value":"election_id"}},{"kind":"Field","name":{"kind":"Name","value":"contest_id"}},{"kind":"Field","name":{"kind":"Name","value":"area_id"}},{"kind":"Field","name":{"kind":"Name","value":"created_at"}},{"kind":"Field","name":{"kind":"Name","value":"last_updated_at"}},{"kind":"Field","name":{"kind":"Name","value":"labels"}},{"kind":"Field","name":{"kind":"Name","value":"annotations"}},{"kind":"Field","name":{"kind":"Name","value":"reviewed_at"}},{"kind":"Field","name":{"kind":"Name","value":"reviewed_by_user_id"}},{"kind":"Field","name":{"kind":"Name","value":"content"}},{"kind":"Field","name":{"kind":"Name","value":"channel"}},{"kind":"Field","name":{"kind":"Name","value":"deleted_at"}},{"kind":"Field","name":{"kind":"Name","value":"created_by_user_id"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"version"}}]}}]}}]} as unknown as DocumentNode; diff --git a/packages/admin-portal/src/queries/GetElectionEventStats.ts b/packages/admin-portal/src/queries/GetElectionEventStats.ts index 88475464f9c..7ea96997056 100644 --- a/packages/admin-portal/src/queries/GetElectionEventStats.ts +++ b/packages/admin-portal/src/queries/GetElectionEventStats.ts @@ -21,10 +21,15 @@ export const GET_ELECTION_EVENT_STATS = gql` ) { total_eligible_voters total_distinct_voters + voters_by_channel { + channel + count + } total_areas total_elections votes_per_day { day + channel day_count } } diff --git a/packages/admin-portal/src/queries/GetElectionStats.ts b/packages/admin-portal/src/queries/GetElectionStats.ts index ebf9c945188..9488b98bd3c 100644 --- a/packages/admin-portal/src/queries/GetElectionStats.ts +++ b/packages/admin-portal/src/queries/GetElectionStats.ts @@ -23,9 +23,14 @@ export const GET_ELECTION_STATS = gql` } ) { total_distinct_voters + voters_by_channel { + channel + count + } total_areas votes_per_day { day + channel day_count } } diff --git a/packages/admin-portal/src/translations/cat.ts b/packages/admin-portal/src/translations/cat.ts index 01d80331015..d512e99da5f 100644 --- a/packages/admin-portal/src/translations/cat.ts +++ b/packages/admin-portal/src/translations/cat.ts @@ -1439,6 +1439,7 @@ const catalanTranslation: TranslationType = { kiosk: "Quiosc", early_voting: "Votació anticipada", telephone: "Votació telefònica", + other: "Altres", }, message: { delete: "Estàs segur que vols esborrar aquest element?", diff --git a/packages/admin-portal/src/translations/en.ts b/packages/admin-portal/src/translations/en.ts index db0ad6a031f..ebe509102c2 100644 --- a/packages/admin-portal/src/translations/en.ts +++ b/packages/admin-portal/src/translations/en.ts @@ -1417,6 +1417,7 @@ const englishTranslation = { kiosk: "Kiosk", early_voting: "Early voting", telephone: "Telephone voting", + other: "Other", }, message: { delete: "Are you sure you want to delete this item?", diff --git a/packages/admin-portal/src/translations/es.ts b/packages/admin-portal/src/translations/es.ts index 371e9ccc46b..a38e27861e2 100644 --- a/packages/admin-portal/src/translations/es.ts +++ b/packages/admin-portal/src/translations/es.ts @@ -1430,6 +1430,7 @@ const spanishTranslation: TranslationType = { kiosk: "Kiosco", early_voting: "Votación anticipada", telephone: "Votación telefónica", + other: "Otros", }, message: { delete: "¿Estás seguro que quieres borrar este elemento?", diff --git a/packages/admin-portal/src/translations/eu.ts b/packages/admin-portal/src/translations/eu.ts index 1a74ab3f3bd..c885d8d5c48 100644 --- a/packages/admin-portal/src/translations/eu.ts +++ b/packages/admin-portal/src/translations/eu.ts @@ -1425,6 +1425,7 @@ const basqueTranslation: TranslationType = { kiosk: "Kiosko", early_voting: "Aurre-botoa", telephone: "Telefono bozketa", + other: "Beste batzuk", }, message: { delete: "Ziur zaude elementu hau ezabatu nahi duzula?", diff --git a/packages/admin-portal/src/translations/fr.ts b/packages/admin-portal/src/translations/fr.ts index 60fbee35415..d642fcaadf4 100644 --- a/packages/admin-portal/src/translations/fr.ts +++ b/packages/admin-portal/src/translations/fr.ts @@ -1436,6 +1436,7 @@ const frenchTranslation: TranslationType = { kiosk: "Kiosque", early_voting: "Vote anticipé", telephone: "Vote par téléphone", + other: "Autre", }, message: { delete: "Êtes-vous sûr de vouloir supprimer cet élément ?", diff --git a/packages/admin-portal/src/translations/gl.ts b/packages/admin-portal/src/translations/gl.ts index f1c4dbfc565..9a469698adb 100644 --- a/packages/admin-portal/src/translations/gl.ts +++ b/packages/admin-portal/src/translations/gl.ts @@ -1430,6 +1430,7 @@ const galegoTranslation: TranslationType = { kiosk: "Quiosco", early_voting: "Votación anticipada", telephone: "Votación telefónica", + other: "Outros", }, message: { delete: "¿Estás seguro de que queres eliminar este elemento?", diff --git a/packages/admin-portal/src/translations/nl.ts b/packages/admin-portal/src/translations/nl.ts index 81a48dfee79..c628b1cac94 100644 --- a/packages/admin-portal/src/translations/nl.ts +++ b/packages/admin-portal/src/translations/nl.ts @@ -1425,6 +1425,7 @@ const dutchTranslation: TranslationType = { kiosk: "Kiosk", early_voting: "Vroeg stemmen", telephone: "Telefonisch stemmen", + other: "Overig", }, message: { delete: "Weet u zeker dat u dit item wilt verwijderen?", diff --git a/packages/admin-portal/src/translations/tl.ts b/packages/admin-portal/src/translations/tl.ts index aafac90b22d..a7a2e55945e 100644 --- a/packages/admin-portal/src/translations/tl.ts +++ b/packages/admin-portal/src/translations/tl.ts @@ -1430,6 +1430,7 @@ const tagalogTranslation: TranslationType = { kiosk: "Kiosk", early_voting: "Maagang pagboto", telephone: "Pagboto sa Telepono", + other: "Iba pa", }, message: { delete: "Sigurado ka bang gusto mong tanggalin ang item na ito?", diff --git a/packages/ballot-verifier/rust/sequent-core-0.1.0.tgz b/packages/ballot-verifier/rust/sequent-core-0.1.0.tgz index 1912c3461cb..ee765982079 100644 Binary files a/packages/ballot-verifier/rust/sequent-core-0.1.0.tgz and b/packages/ballot-verifier/rust/sequent-core-0.1.0.tgz differ diff --git a/packages/electoral-log/src/lib.rs b/packages/electoral-log/src/lib.rs index cac2b913cb0..4fab970d222 100644 --- a/packages/electoral-log/src/lib.rs +++ b/packages/electoral-log/src/lib.rs @@ -14,6 +14,13 @@ pub fn get_schema_version() -> String { "1".to_string() } +/// Returns the minimum reader schema version required for a row containing the +/// append-only `CastVoteWithChannel` body. The stored version is compatibility +/// metadata for readers; it does not change the encoding of other statements. +pub fn get_cast_vote_channel_schema_version() -> String { + "2".to_string() +} + pub fn timestamp() -> Timestamp { let start = SystemTime::now(); let since_the_epoch = start diff --git a/packages/electoral-log/src/messages/message.rs b/packages/electoral-log/src/messages/message.rs index 8f864e79261..9799d7abcd7 100644 --- a/packages/electoral-log/src/messages/message.rs +++ b/packages/electoral-log/src/messages/message.rs @@ -100,6 +100,61 @@ impl Message { ) -> Result { let body = StatementBody::CastVote(election.clone(), pseudonym_h, vote_h.clone(), ip, country); + Self::cast_vote_message_from_body( + event, + election, + vote_h, + body, + sd, + voter_id, + voter_username, + area_id, + ) + } + + pub fn cast_vote_with_channel_message( + event: EventIdString, + election: ElectionIdString, + pseudonym_h: PseudonymHash, + vote_h: CastVoteHash, + sd: &SigningData, + ip: VoterIpString, + country: VoterCountryString, + voting_channel: VotingChannelString, + voter_id: Option, + voter_username: Option, + area_id: String, + ) -> Result { + let body = StatementBody::CastVoteWithChannel( + election.clone(), + pseudonym_h, + vote_h.clone(), + ip, + country, + voting_channel, + ); + Self::cast_vote_message_from_body( + event, + election, + vote_h, + body, + sd, + voter_id, + voter_username, + area_id, + ) + } + + fn cast_vote_message_from_body( + event: EventIdString, + election: ElectionIdString, + vote_h: CastVoteHash, + body: StatementBody, + sd: &SigningData, + voter_id: Option, + voter_username: Option, + area_id: String, + ) -> Result { let ballot_id: String = vote_h .0 .into_inner() @@ -547,6 +602,13 @@ impl TryFrom<&Message> for ElectoralLogMessage { type Error = anyhow::Error; fn try_from(message: &Message) -> Result { + let version = match &message.statement.body { + StatementBody::CastVoteWithChannel(_, _, _, _, _, _) => { + crate::get_cast_vote_channel_schema_version() + } + _ => crate::get_schema_version(), + }; + Ok(ElectoralLogMessage { id: 0, created: crate::timestamp() as i64, @@ -554,7 +616,7 @@ impl TryFrom<&Message> for ElectoralLogMessage { statement_kind: message.statement.head.kind.to_string(), message: message.strand_serialize()?, sender_pk: message.sender.pk.to_der_b64_string()?, - version: crate::get_schema_version(), + version, user_id: message.user_id.clone(), username: message.username.clone(), election_id: message.election_id.clone(), @@ -634,4 +696,48 @@ mod tests { )); Ok(()) } + + #[test] + fn only_channel_aware_cast_votes_use_schema_version_two() -> Result<()> { + let signing_data = SigningData::new( + StrandSignatureSk::r#gen()?, + "windmill", + StrandSignatureSk::r#gen()?, + ); + let legacy = Message::cast_vote_message( + EventIdString("event-id".to_string()), + ElectionIdString(Some("election-id".to_string())), + PseudonymHash::new([1; 64]), + CastVoteHash::new([2; 64]), + &signing_data, + VoterIpString("ip".to_string()), + VoterCountryString("country".to_string()), + Some("voter-id".to_string()), + None, + "area-id".to_string(), + )?; + let with_channel = Message::cast_vote_with_channel_message( + EventIdString("event-id".to_string()), + ElectionIdString(Some("election-id".to_string())), + PseudonymHash::new([1; 64]), + CastVoteHash::new([2; 64]), + &signing_data, + VoterIpString("ip".to_string()), + VoterCountryString("country".to_string()), + VotingChannelString("TELEPHONE".to_string()), + Some("voter-id".to_string()), + None, + "area-id".to_string(), + )?; + + let legacy_row: ElectoralLogMessage = (&legacy).try_into()?; + let with_channel_row: ElectoralLogMessage = (&with_channel).try_into()?; + assert_eq!(legacy_row.version, "1"); + assert_eq!(with_channel_row.version, "2"); + assert_eq!( + with_channel.statement.head.description, + "Inserted cast vote. Voting channel: TELEPHONE." + ); + Ok(()) + } } diff --git a/packages/electoral-log/src/messages/statement.rs b/packages/electoral-log/src/messages/statement.rs index 7d9d45a9913..796752e3f28 100644 --- a/packages/electoral-log/src/messages/statement.rs +++ b/packages/electoral-log/src/messages/statement.rs @@ -8,7 +8,6 @@ use std::fmt::Debug; use strum_macros::Display; use crate::messages::newtypes::{CertificateAuthEventAction, *}; -use tracing::info; #[derive(BorshSerialize, BorshDeserialize, Deserialize, Serialize, Debug)] pub struct Statement { @@ -48,6 +47,14 @@ impl StatementHead { description: "Inserted cast vote.".to_string(), ..default_head }, + StatementBody::CastVoteWithChannel(_, _, _, _, _, channel) => StatementHead { + kind: StatementType::CastVote, + description: format!( + "Inserted cast vote. Voting channel: {channel}.", + channel = channel.0 + ), + ..default_head + }, StatementBody::CastVoteError(_, _, _, _, _) => StatementHead { kind: StatementType::CastVoteError, log_type: StatementLogType::ERROR, @@ -373,6 +380,21 @@ pub enum StatementBody { ExtApiName, String, ), + /// Cast-vote statement carrying its source channel. This separate, + /// append-only variant keeps existing Borsh-encoded `CastVote` messages + /// deserializable. + /// + /// Rollout invariant: every electoral-log reader (including released + /// `step-cli` and external auditors) must be upgraded before writers emit + /// this variant. Older readers cannot decode a variant they do not know. + CastVoteWithChannel( + ElectionIdString, + PseudonymHash, + CastVoteHash, + VoterIpString, + VoterCountryString, + VotingChannelString, + ), } // Note: When creating new variants, consider that the length limit STATEMENT_KIND_VARCHAR_LENGTH is 40. @@ -415,7 +437,7 @@ pub enum StatementEventType { } #[cfg(test)] -mod tests { +mod statement_compatibility_tests { use super::*; fn external_api_request_description(operation: &str) -> String { @@ -481,11 +503,39 @@ mod tests { ExtApiName::Datafix, String::new(), ); + let cast_vote_with_channel = StatementBody::CastVoteWithChannel( + ElectionIdString(None), + PseudonymHash::new([0; 64]), + CastVoteHash::new([0; 64]), + VoterIpString(String::new()), + VoterCountryString(String::new()), + VotingChannelString(String::new()), + ); assert_eq!(borsh::to_vec(&election_publish).unwrap()[0], 2); assert_eq!(borsh::to_vec(&certificate).unwrap()[0], 23); assert_eq!(borsh::to_vec(&phone).unwrap()[0], 24); - assert_eq!(borsh::to_vec(&external).unwrap()[0], 25); + // `ExternalApiRequest` follows `ResultsPublicationAction` in the + // released enum. The previous expectation of 25 was left stale by the + // rebase that introduced `ExternalApiRequest`. + assert_eq!(borsh::to_vec(&external).unwrap()[0], 26); + assert_eq!(borsh::to_vec(&cast_vote_with_channel).unwrap()[0], 27); + } + + #[test] + fn legacy_cast_vote_body_remains_deserializable() { + let legacy = StatementBody::CastVote( + ElectionIdString(Some("election-id".to_string())), + PseudonymHash::new([1; 64]), + CastVoteHash::new([2; 64]), + VoterIpString("ip".to_string()), + VoterCountryString("country".to_string()), + ); + + let bytes = borsh::to_vec(&legacy).unwrap(); + assert_eq!(bytes[0], 0); + let decoded: StatementBody = borsh::from_slice(&bytes).unwrap(); + assert!(matches!(decoded, StatementBody::CastVote(_, _, _, _, _))); } #[test] @@ -503,9 +553,13 @@ mod tests { 25 ); assert_eq!( - borsh::to_vec(&StatementType::ExternalApiRequest).unwrap()[0], + borsh::to_vec(&StatementType::ResultsPublicationAction).unwrap()[0], 26 ); + assert_eq!( + borsh::to_vec(&StatementType::ExternalApiRequest).unwrap()[0], + 27 + ); } #[test] @@ -545,7 +599,7 @@ pub enum StatementLogType { } #[cfg(test)] -mod tests { +mod results_publication_tests { use super::*; #[test] diff --git a/packages/harvest/src/routes/election_event_stats.rs b/packages/harvest/src/routes/election_event_stats.rs index fcf1d698531..1d7cae0496d 100644 --- a/packages/harvest/src/routes/election_event_stats.rs +++ b/packages/harvest/src/routes/election_event_stats.rs @@ -14,12 +14,13 @@ use sequent_core::types::permissions::Permissions; use serde::{Deserialize, Serialize}; use tracing::instrument; use windmill::services::cast_votes::{ - get_count_votes_per_day, get_top_count_votes_by_ip, CastVoteCountByIp, - CastVotesPerDay, ListCastVotesByIpFilter, + get_count_distinct_voters_by_channel, get_count_votes_per_day, + get_top_count_votes_by_ip, CastVoteCountByIp, CastVotesPerDay, + ListCastVotesByIpFilter, VotersByChannel, }; use windmill::services::database::{get_hasura_pool, get_keycloak_pool}; use windmill::services::election_event_statistics::{ - get_count_areas, get_count_distinct_voters, get_count_elections, + get_count_areas, get_count_elections, }; use windmill::services::users::count_keycloak_enabled_users; @@ -35,6 +36,7 @@ pub struct ElectionEventStatsInput { pub struct ElectionEventStatsOutput { total_eligible_voters: i64, total_distinct_voters: i64, + voters_by_channel: Vec, total_areas: i64, total_elections: i64, votes_per_day: Vec, @@ -84,18 +86,22 @@ pub async fn get_election_event_stats( ) })?; - let total_distinct_voters: i64 = get_count_distinct_voters( + let voters_by_channel = get_count_distinct_voters_by_channel( &hasura_transaction, - &tenant_id.as_str(), - &input.election_event_id.as_str(), + tenant_id.as_str(), + input.election_event_id.as_str(), + None, ) .await .map_err(|err| { ( Status::InternalServerError, - format!("Error retrieving total_distinct_voters: {err}"), + format!("Error retrieving voters_by_channel: {err}"), ) })?; + let total_distinct_voters: i64 = + voters_by_channel.iter().map(|item| item.count).sum(); + let total_elections: i64 = get_count_elections( &hasura_transaction, &tenant_id.as_str(), @@ -149,6 +155,7 @@ pub async fn get_election_event_stats( Ok(Json(ElectionEventStatsOutput { total_distinct_voters, + voters_by_channel, total_areas, total_eligible_voters: total_eligible_voters.into(), total_elections: total_elections.into(), diff --git a/packages/harvest/src/routes/election_stats.rs b/packages/harvest/src/routes/election_stats.rs index 4bb55e0fe53..ad84b73e853 100644 --- a/packages/harvest/src/routes/election_stats.rs +++ b/packages/harvest/src/routes/election_stats.rs @@ -12,11 +12,11 @@ use sequent_core::types::permissions::Permissions; use serde::{Deserialize, Serialize}; use tracing::instrument; use windmill::services::cast_votes::{ - get_count_votes_per_day, CastVotesPerDay, + get_count_distinct_voters_by_channel, get_count_votes_per_day, + CastVotesPerDay, VotersByChannel, }; use windmill::services::database::get_hasura_pool; use windmill::services::election_statistics::get_count_areas; -use windmill::services::election_statistics::get_count_distinct_voters; #[derive(Serialize, Deserialize, Debug)] pub struct ElectionStatsInput { @@ -30,6 +30,7 @@ pub struct ElectionStatsInput { #[derive(Serialize, Deserialize, Debug)] pub struct ElectionStatsOutput { total_distinct_voters: i64, + voters_by_channel: Vec, total_areas: i64, votes_per_day: Vec, } @@ -64,19 +65,22 @@ pub async fn get_election_stats( ) })?; - let total_distinct_voters: i64 = get_count_distinct_voters( + let voters_by_channel = get_count_distinct_voters_by_channel( &hasura_transaction, - &tenant_id.as_str(), - &input.election_event_id.as_str(), - &input.election_id.as_str(), + tenant_id.as_str(), + input.election_event_id.as_str(), + Some(input.election_id.as_str()), ) .await .map_err(|err| { ( Status::InternalServerError, - format!("Error retrieving total_distinct_voters: {err}"), + format!("Error retrieving voters_by_channel: {err}"), ) })?; + let total_distinct_voters: i64 = + voters_by_channel.iter().map(|item| item.count).sum(); + let total_areas: i64 = get_count_areas( &hasura_transaction, &tenant_id.as_str(), @@ -110,6 +114,7 @@ pub async fn get_election_stats( Ok(Json(ElectionStatsOutput { total_distinct_voters, + voters_by_channel, total_areas, votes_per_day, })) diff --git a/packages/step-cli/src/commands/export_cast_votes.rs b/packages/step-cli/src/commands/export_cast_votes.rs index 046e4731dd1..5d20e5a7e02 100644 --- a/packages/step-cli/src/commands/export_cast_votes.rs +++ b/packages/step-cli/src/commands/export_cast_votes.rs @@ -11,9 +11,10 @@ use clap::Args; use colored::Colorize; use csv::WriterBuilder; use electoral_log::messages::message::Message; -use electoral_log::messages::newtypes::ElectionIdString; +use electoral_log::messages::newtypes::{CastVoteHash, ElectionIdString, PseudonymHash}; use electoral_log::messages::statement::{StatementBody, StatementType}; use electoral_log::{BoardClient, ElectoralLogVarCharColumn, SqlCompOperators}; +use sequent_core::ballot::VotingStatusChannel; use sequent_core::encrypt::shorten_hash; use serde::Serialize; use serde_json::Value; @@ -32,6 +33,36 @@ struct Record { area_id: Option, hash_voter_id: String, ballot_id: String, + voting_channel: String, +} + +struct CastVoteExportFields<'a> { + election_id: &'a ElectionIdString, + pseudonym_hash: &'a PseudonymHash, + cast_vote_hash: &'a CastVoteHash, + voting_channel: String, +} + +fn cast_vote_export_fields(body: &StatementBody) -> Option> { + match body { + StatementBody::CastVote(election_id, pseudonym, cast_vote, _, _) => { + Some(CastVoteExportFields { + election_id, + pseudonym_hash: pseudonym, + cast_vote_hash: cast_vote, + voting_channel: VotingStatusChannel::ONLINE.to_string(), + }) + } + StatementBody::CastVoteWithChannel(election_id, pseudonym, cast_vote, _, _, channel) => { + Some(CastVoteExportFields { + election_id, + pseudonym_hash: pseudonym, + cast_vote_hash: cast_vote, + voting_channel: channel.0.clone(), + }) + } + _ => None, + } } #[derive(Args)] @@ -103,24 +134,22 @@ impl ExportCastVotes { let message: &Message = &Message::strand_deserialize(&electoral_log_message.message) .map_err(|err| anyhow!("Failed to deserialize message: {:?}", err))?; - if let StatementBody::CastVote( - election_id_string, - pseudonym_hash, - cast_vote_hash, - _voter_ip, - _voter_country, - ) = &message.statement.body - { - writer - .serialize(Record { - created: electoral_log_message.created, - election_id: election_id_string.clone(), - hash_voter_id: hex::encode(pseudonym_hash.0.clone().to_inner()), - ballot_id: hex::encode(shorten_hash(&cast_vote_hash.0.clone().to_inner())), - area_id: electoral_log_message.area_id.clone(), - }) - .map_err(|error| anyhow!("Failed to write row {}", error))?; + let Some(fields) = cast_vote_export_fields(&message.statement.body) else { + continue; }; + + writer + .serialize(Record { + created: electoral_log_message.created, + election_id: fields.election_id.clone(), + hash_voter_id: hex::encode(fields.pseudonym_hash.0.clone().to_inner()), + ballot_id: hex::encode(shorten_hash( + &fields.cast_vote_hash.0.clone().to_inner(), + )), + area_id: electoral_log_message.area_id.clone(), + voting_channel: fields.voting_channel, + }) + .map_err(|error| anyhow!("Failed to write row {}", error))?; } writer @@ -130,3 +159,46 @@ impl ExportCastVotes { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use electoral_log::messages::newtypes::{ + VoterCountryString, VoterIpString, VotingChannelString, + }; + + fn cast_vote_body() -> StatementBody { + StatementBody::CastVote( + ElectionIdString(Some("election-id".to_string())), + PseudonymHash::new([1; 64]), + CastVoteHash::new([2; 64]), + VoterIpString("ip".to_string()), + VoterCountryString("country".to_string()), + ) + } + + #[test] + fn legacy_cast_votes_export_as_online() { + let body = cast_vote_body(); + let fields = cast_vote_export_fields(&body).unwrap(); + + assert_eq!(fields.voting_channel, "ONLINE"); + assert_eq!(fields.election_id.0.as_deref(), Some("election-id")); + } + + #[test] + fn channel_aware_cast_votes_export_the_stored_channel() { + let body = StatementBody::CastVoteWithChannel( + ElectionIdString(Some("election-id".to_string())), + PseudonymHash::new([1; 64]), + CastVoteHash::new([2; 64]), + VoterIpString("ip".to_string()), + VoterCountryString("country".to_string()), + VotingChannelString("TELEPHONE".to_string()), + ); + let fields = cast_vote_export_fields(&body).unwrap(); + + assert_eq!(fields.voting_channel, "TELEPHONE"); + assert_eq!(fields.election_id.0.as_deref(), Some("election-id")); + } +} diff --git a/packages/step-cli/src/tests/e2e.rs b/packages/step-cli/src/tests/e2e.rs index bc33fe453d3..3d14b352784 100644 --- a/packages/step-cli/src/tests/e2e.rs +++ b/packages/step-cli/src/tests/e2e.rs @@ -17,9 +17,10 @@ use crate::{ utils::areas::get_areas::GetAreas, }; use sequent_core::{ballot::VotingStatus, types::ceremonies::TallyType}; -use std::{env, error::Error, fmt::format}; +use std::{env, error::Error}; #[test] +#[ignore = "requires a fully configured election environment"] fn run_e2e() -> Result<(), Box> { // TODO: Add environment Variables in dev.env + beyond + gitops - Do this for the variables in this file + the variables in init_loadero.rs @@ -57,7 +58,7 @@ fn run_e2e() -> Result<(), Box> { for i in 11..(voter_sim_number + 1) { let name = format!("test{}", i); let pass = format!("password{}", i); - let user_id = create_voter(&election_event_id, &name, &name, &name, "")?; + let user_id = create_voter(&election_event_id, &name, &name, &name, "", "")?; // Call Edit to update password and area id for voter edit_voter( &election_event_id, @@ -116,8 +117,8 @@ fn run_e2e() -> Result<(), Box> { )?; // Step 3: Start Election - Update Status - let status = update_event_voting_status::VotingStatus::OPEN; - update_event_voting_status(&election_event_id, &status)?; + let status = VotingStatus::OPEN; + update_event_voting_status(&election_event_id, &status, &None)?; // Step 3.5 : Create Publication publish_changes(&election_event_id, None)?; diff --git a/packages/ui-core/rust/sequent-core-0.1.0.tgz b/packages/ui-core/rust/sequent-core-0.1.0.tgz index 1912c3461cb..ee765982079 100644 Binary files a/packages/ui-core/rust/sequent-core-0.1.0.tgz and b/packages/ui-core/rust/sequent-core-0.1.0.tgz differ diff --git a/packages/voting-portal/rust/sequent-core-0.1.0.tgz b/packages/voting-portal/rust/sequent-core-0.1.0.tgz index 1912c3461cb..ee765982079 100644 Binary files a/packages/voting-portal/rust/sequent-core-0.1.0.tgz and b/packages/voting-portal/rust/sequent-core-0.1.0.tgz differ diff --git a/packages/windmill/src/postgres/cast_vote.rs b/packages/windmill/src/postgres/cast_vote.rs index 8ce57766ceb..57e0c5415da 100644 --- a/packages/windmill/src/postgres/cast_vote.rs +++ b/packages/windmill/src/postgres/cast_vote.rs @@ -4,13 +4,133 @@ use crate::services::cast_votes::{CastVote, CastVoteStatus}; use anyhow::{anyhow, Result}; use deadpool_postgres::Transaction; +use sequent_core::ballot::VotingStatusChannel; use sequent_core::services::uuid_validation::parse_uuid_v4; -use serde_json::json; -use serde_json::value::Value; +use serde::Serialize; +use serde_json::Value; use tokio_postgres::row::Row; use tracing::instrument; use uuid::Uuid; +#[derive(Serialize)] +struct CastVoteAnnotations<'a> { + ip: Option<&'a str>, + country: Option<&'a str>, + voting_channel: VotingStatusChannel, +} + +#[derive(Clone, Copy)] +pub(crate) enum CastVoteRelation { + Production, + #[cfg(test)] + StatisticsTest, +} + +impl CastVoteRelation { + /// PostgreSQL identifiers cannot be query parameters. Keeping the relation + /// selector closed prevents caller-controlled text from reaching the SQL. + fn sql_identifier(self) -> &'static str { + match self { + Self::Production => "sequent_backend.cast_vote", + #[cfg(test)] + Self::StatisticsTest => "pg_temp.cast_vote_stats_test", + } + } +} + +pub(crate) fn count_distinct_voters_by_channel_query( + cast_vote_relation: CastVoteRelation, + filter_by_election: bool, +) -> String { + let election_filter = if filter_by_election { + "AND election_id = $5" + } else { + "" + }; + let cast_vote_relation = cast_vote_relation.sql_identifier(); + + format!( + r#" + WITH latest_valid_votes AS ( + SELECT DISTINCT ON (voter_id_string) + voter_id_string, + COALESCE(annotations->>'voting_channel', $4) AS channel + FROM {cast_vote_relation} + WHERE + tenant_id = $1 AND + election_event_id = $2 AND + status = $3 AND + voter_id_string IS NOT NULL + {election_filter} + ORDER BY + voter_id_string, + created_at DESC NULLS LAST, + id DESC + ) + SELECT + channel, + COUNT(*) AS count + FROM latest_valid_votes + GROUP BY channel + ORDER BY channel; + "# + ) +} + +pub(crate) fn count_votes_per_day_query(cast_vote_relation: CastVoteRelation) -> String { + let cast_vote_relation = cast_vote_relation.sql_identifier(); + + format!( + r#" + WITH date_series AS ( + SELECT + (t.day)::date AS day + FROM + generate_series( + $3::date, + $4::date, + interval '1 day' + ) AS t(day) + ) + SELECT + ds.day, + COALESCE(v.annotations->>'voting_channel', $8) AS channel, + COUNT(v.id) AS day_count + FROM + date_series ds + LEFT JOIN {cast_vote_relation} v ON ds.day = DATE(v.created_at AT TIME ZONE $5) + AND v.tenant_id = $1 + AND v.election_event_id = $2 + AND (v.election_id = $6 OR $6 IS NULL) + AND v.status = $7 + WHERE + ( + DATE(v.created_at AT TIME ZONE $5) >= $3 AND + DATE(v.created_at AT TIME ZONE $5) <= $4 + ) + OR v.created_at IS NULL + GROUP BY + ds.day, + COALESCE(v.annotations->>'voting_channel', $8) + ORDER BY + ds.day, + channel; + "# + ) +} + +fn cast_vote_annotations( + voter_ip: &Option, + voter_country: &Option, + voting_channel: VotingStatusChannel, +) -> Result { + Ok(serde_json::to_value(CastVoteAnnotations { + ip: voter_ip.as_deref(), + country: voter_country.as_deref(), + voting_channel, + })?) +} + #[instrument(skip(hasura_transaction, content, cast_ballot_signature), err)] pub async fn insert_cast_vote( hasura_transaction: &Transaction<'_>, @@ -24,6 +144,7 @@ pub async fn insert_cast_vote( cast_ballot_signature: &[u8], voter_ip: &Option, voter_country: &Option, + voting_channel: VotingStatusChannel, initial_status: CastVoteStatus, ) -> Result { let status = initial_status.to_string(); @@ -66,10 +187,7 @@ pub async fn insert_cast_vote( ) .await?; - let annotations: Value = json!({ - "ip": voter_ip, - "country": voter_country, - }); + let annotations = cast_vote_annotations(voter_ip, voter_country, voting_channel)?; let rows: Vec = hasura_transaction .query( @@ -102,6 +220,25 @@ pub async fn insert_cast_vote( } } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cast_vote_annotations_include_voting_channel() { + let annotations = cast_vote_annotations( + &Some("203.0.113.1".to_string()), + &Some("CO".to_string()), + VotingStatusChannel::TELEPHONE, + ) + .unwrap(); + + assert_eq!(annotations["ip"], "203.0.113.1"); + assert_eq!(annotations["country"], "CO"); + assert_eq!(annotations["voting_channel"], "TELEPHONE"); + } +} + /// Atomically moves a cast vote from `expected_status` to `new_status`, scoped /// to its tenant and event. Returns `false` without any change when the row is /// no longer in `expected_status`, so concurrent workers cannot apply the same diff --git a/packages/windmill/src/services/cast_votes.rs b/packages/windmill/src/services/cast_votes.rs index 2e69db9a47c..edd98bdf43c 100644 --- a/packages/windmill/src/services/cast_votes.rs +++ b/packages/windmill/src/services/cast_votes.rs @@ -3,6 +3,9 @@ // SPDX-License-Identifier: AGPL-3.0-only use super::database::PgConfig; use super::sql_utils::escape_sql_literal; +use crate::postgres::cast_vote::{ + count_distinct_voters_by_channel_query, count_votes_per_day_query, CastVoteRelation, +}; use crate::services::datafix::utils::{ is_datafix_election_event_by_id, voted_via_not_internet_channel, }; @@ -12,6 +15,7 @@ use chrono::NaiveDate; use chrono::{DateTime, Utc}; use deadpool_postgres::Transaction; use futures::TryStreamExt; +use sequent_core::ballot::VotingStatusChannel; use sequent_core::services::uuid_validation::parse_uuid_v4; use sequent_core::types::keycloak::{User, VotesInfo}; use serde::{Deserialize, Serialize}; @@ -263,6 +267,7 @@ impl TryFrom for ElectionCastVotes { #[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone)] pub struct CastVotesPerDay { pub day: String, + pub channel: String, pub day_count: i64, } @@ -271,11 +276,91 @@ impl TryFrom for CastVotesPerDay { fn try_from(item: Row) -> Result { Ok(CastVotesPerDay { day: item.try_get::<_, chrono::NaiveDate>("day")?.to_string(), + channel: item.try_get("channel")?, day_count: item.try_get::<_, i64>("day_count")?, }) } } +#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone)] +pub struct VotersByChannel { + pub channel: String, + pub count: i64, +} + +impl TryFrom for VotersByChannel { + type Error = anyhow::Error; + + fn try_from(item: Row) -> Result { + Ok(VotersByChannel { + channel: item.try_get("channel")?, + count: item.try_get("count")?, + }) + } +} + +/// Counts each voter once under the channel of their latest valid vote. +/// Votes created before the channel annotation was introduced are online. +#[instrument(skip(transaction), err)] +pub async fn get_count_distinct_voters_by_channel( + transaction: &Transaction<'_>, + tenant_id: &str, + election_event_id: &str, + election_id: Option<&str>, +) -> Result> { + get_count_distinct_voters_by_channel_from_relation( + transaction, + tenant_id, + election_event_id, + election_id, + CastVoteRelation::Production, + ) + .await +} + +async fn get_count_distinct_voters_by_channel_from_relation( + transaction: &Transaction<'_>, + tenant_id: &str, + election_event_id: &str, + election_id: Option<&str>, + cast_vote_relation: CastVoteRelation, +) -> Result> { + let election_id = election_id.map(parse_uuid_v4).transpose()?; + let status = CastVoteStatus::Valid.to_string(); + let default_channel = VotingStatusChannel::ONLINE.to_string(); + let sql = count_distinct_voters_by_channel_query(cast_vote_relation, election_id.is_some()); + let statement = transaction.prepare(&sql).await?; + + let tenant_id = parse_uuid_v4(tenant_id)?; + let election_event_id = parse_uuid_v4(election_event_id)?; + let rows = match election_id { + Some(election_id) => { + transaction + .query( + &statement, + &[ + &tenant_id, + &election_event_id, + &status, + &default_channel, + &election_id, + ], + ) + .await? + } + None => { + transaction + .query( + &statement, + &[&tenant_id, &election_event_id, &status, &default_channel], + ) + .await? + } + }; + + rows.into_iter().map(TryInto::try_into).collect() +} + #[instrument(err)] pub async fn count_cast_votes_election( hasura_transaction: &Transaction<'_>, @@ -335,6 +420,29 @@ pub async fn get_count_votes_per_day( end_date: &str, election_id: Option, user_timezone: &str, +) -> Result> { + get_count_votes_per_day_from_relation( + transaction, + tenant_id, + election_event_id, + start_date, + end_date, + election_id, + user_timezone, + CastVoteRelation::Production, + ) + .await +} + +async fn get_count_votes_per_day_from_relation( + transaction: &Transaction<'_>, + tenant_id: &str, + election_event_id: &str, + start_date: &str, + end_date: &str, + election_id: Option, + user_timezone: &str, + cast_vote_relation: CastVoteRelation, ) -> Result> { let start_date_naive = NaiveDate::parse_from_str(start_date, "%Y-%m-%d") .with_context(|| "Error parsing start_date")?; @@ -345,51 +453,9 @@ pub async fn get_count_votes_per_day( None => None, }; let status = CastVoteStatus::Valid.to_string(); - let total_areas_statement = transaction - .prepare( - format!( - r#" - WITH date_series AS ( - SELECT - (t.day)::date AS day - FROM - generate_series( - $3::date, - $4::date, - interval '1 day' - ) AS t(day) - ) - SELECT - ds.day, - COALESCE( - COUNT( - CASE - WHEN DATE(v.created_at AT TIME ZONE $5) = ds.day THEN 1 - ELSE NULL - END - ), - 0 - ) AS day_count - FROM - date_series ds - LEFT JOIN sequent_backend.cast_vote v ON ds.day = DATE(v.created_at AT TIME ZONE $5) - AND v.tenant_id = $1 - AND v.election_event_id = $2 - AND (v.election_id = $6 OR $6 IS NULL) - AND v.status = $7 - WHERE - ( - DATE(v.created_at AT TIME ZONE $5) >= $3 AND - DATE(v.created_at AT TIME ZONE $5) <= $4 - ) - OR v.created_at IS NULL - GROUP BY ds.day - ORDER BY ds.day; - "# - ) - .as_str(), - ) - .await?; + let default_channel = VotingStatusChannel::ONLINE.to_string(); + let sql = count_votes_per_day_query(cast_vote_relation); + let total_areas_statement = transaction.prepare(&sql).await?; let rows: Vec = transaction .query( @@ -402,6 +468,7 @@ pub async fn get_count_votes_per_day( &user_timezone, &election_uuid, &status, + &default_channel, ], ) .await?; @@ -835,3 +902,133 @@ pub async fn count_cast_votes_election_event( Ok(count) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::services::database::generate_hasura_pool; + + const TENANT_ID: &str = "10000000-0000-4000-8000-000000000001"; + const ELECTION_EVENT_ID: &str = "10000000-0000-4000-8000-000000000002"; + const ELECTION_ID: &str = "10000000-0000-4000-8000-000000000003"; + + fn counts_by_channel(rows: Vec) -> HashMap { + rows.into_iter() + .map(|row| (row.channel, row.count)) + .collect() + } + + fn counts_by_day_and_channel(rows: Vec) -> HashMap<(String, String), i64> { + rows.into_iter() + .map(|row| ((row.day, row.channel), row.day_count)) + .collect() + } + + #[tokio::test] + #[ignore = "requires PostgreSQL configured through HASURA_DB__*; exercised by the dedicated CI job"] + async fn voters_by_channel_defaults_legacy_votes_and_uses_latest_valid_revote() { + let pool = generate_hasura_pool().await.unwrap(); + let mut client = pool.get().await.unwrap(); + let transaction = client.transaction().await.unwrap(); + + transaction + .batch_execute( + r#" + CREATE TEMP TABLE cast_vote_stats_test ( + id UUID PRIMARY KEY, + tenant_id UUID NOT NULL, + election_event_id UUID NOT NULL, + election_id UUID NOT NULL, + voter_id_string TEXT, + status TEXT NOT NULL, + annotations JSONB, + created_at TIMESTAMPTZ + ); + + INSERT INTO cast_vote_stats_test ( + id, + tenant_id, + election_event_id, + election_id, + voter_id_string, + status, + annotations, + created_at + ) VALUES + ('10000000-0000-4000-8000-000000000010', '10000000-0000-4000-8000-000000000001', '10000000-0000-4000-8000-000000000002', '10000000-0000-4000-8000-000000000003', 'legacy-voter', 'valid', '{}', '2026-01-01T00:00:00Z'), + ('10000000-0000-4000-8000-000000000011', '10000000-0000-4000-8000-000000000001', '10000000-0000-4000-8000-000000000002', '10000000-0000-4000-8000-000000000003', 'revoting-voter', 'valid', '{"voting_channel":"KIOSK"}', '2026-01-01T00:00:00Z'), + ('10000000-0000-4000-8000-000000000012', '10000000-0000-4000-8000-000000000001', '10000000-0000-4000-8000-000000000002', '10000000-0000-4000-8000-000000000003', 'revoting-voter', 'valid', '{"voting_channel":"TELEPHONE"}', '2026-01-02T00:00:00Z'), + ('10000000-0000-4000-8000-000000000013', '10000000-0000-4000-8000-000000000001', '10000000-0000-4000-8000-000000000002', '10000000-0000-4000-8000-000000000003', 'discarded-revote-voter', 'valid', '{"voting_channel":"KIOSK"}', '2026-01-01T00:00:00Z'), + ('10000000-0000-4000-8000-000000000014', '10000000-0000-4000-8000-000000000001', '10000000-0000-4000-8000-000000000002', '10000000-0000-4000-8000-000000000003', 'discarded-revote-voter', 'discarded', '{"voting_channel":"TELEPHONE"}', '2026-01-02T00:00:00Z'), + ('10000000-0000-4000-8000-000000000015', '10000000-0000-4000-8000-000000000001', '10000000-0000-4000-8000-000000000002', '10000000-0000-4000-8000-000000000004', 'second-election-voter', 'valid', '{"voting_channel":"ONLINE"}', '2026-01-01T00:00:00Z'), + ('10000000-0000-4000-8000-000000000016', '10000000-0000-4000-8000-000000000001', '10000000-0000-4000-8000-000000000002', '10000000-0000-4000-8000-000000000003', NULL, 'valid', '{"voting_channel":"ONLINE"}', '2026-01-01T00:00:00Z'), + ('10000000-0000-4000-8000-000000000017', '20000000-0000-4000-8000-000000000001', '10000000-0000-4000-8000-000000000002', '10000000-0000-4000-8000-000000000003', 'other-tenant-voter', 'valid', '{"voting_channel":"ONLINE"}', '2026-01-01T00:00:00Z'); + "#, + ) + .await + .unwrap(); + + let event_counts = counts_by_channel( + get_count_distinct_voters_by_channel_from_relation( + &transaction, + TENANT_ID, + ELECTION_EVENT_ID, + None, + CastVoteRelation::StatisticsTest, + ) + .await + .unwrap(), + ); + assert_eq!(event_counts.get("ONLINE"), Some(&2)); + assert_eq!(event_counts.get("KIOSK"), Some(&1)); + assert_eq!(event_counts.get("TELEPHONE"), Some(&1)); + + let election_counts = counts_by_channel( + get_count_distinct_voters_by_channel_from_relation( + &transaction, + TENANT_ID, + ELECTION_EVENT_ID, + Some(ELECTION_ID), + CastVoteRelation::StatisticsTest, + ) + .await + .unwrap(), + ); + assert_eq!(election_counts.get("ONLINE"), Some(&1)); + assert_eq!(election_counts.get("KIOSK"), Some(&1)); + assert_eq!(election_counts.get("TELEPHONE"), Some(&1)); + + let votes_per_day = counts_by_day_and_channel( + get_count_votes_per_day_from_relation( + &transaction, + TENANT_ID, + ELECTION_EVENT_ID, + "2026-01-01", + "2026-01-03", + Some(ELECTION_ID.to_string()), + "UTC", + CastVoteRelation::StatisticsTest, + ) + .await + .unwrap(), + ); + assert_eq!( + votes_per_day.get(&("2026-01-01".to_string(), "ONLINE".to_string())), + Some(&2) + ); + assert_eq!( + votes_per_day.get(&("2026-01-01".to_string(), "KIOSK".to_string())), + Some(&2) + ); + assert_eq!( + votes_per_day.get(&("2026-01-02".to_string(), "TELEPHONE".to_string())), + Some(&1) + ); + assert_eq!( + votes_per_day.get(&("2026-01-03".to_string(), "ONLINE".to_string())), + Some(&0) + ); + + transaction.rollback().await.unwrap(); + } +} diff --git a/packages/windmill/src/services/election_event_statistics.rs b/packages/windmill/src/services/election_event_statistics.rs index 697bc0415ba..33feb5039a0 100644 --- a/packages/windmill/src/services/election_event_statistics.rs +++ b/packages/windmill/src/services/election_event_statistics.rs @@ -1,7 +1,6 @@ // SPDX-FileCopyrightText: 2025 Sequent Tech Inc // // SPDX-License-Identifier: AGPL-3.0-only -use crate::services::cast_votes::CastVoteStatus; use anyhow::Result; use deadpool_postgres::Transaction; use sequent_core::services::uuid_validation::parse_uuid_v4; @@ -140,47 +139,3 @@ pub async fn update_election_event_statistics( Ok(()) } - -#[instrument(skip(transaction), err)] -pub async fn get_count_distinct_voters( - transaction: &Transaction<'_>, - tenant_id: &str, - election_event_id: &str, -) -> Result { - let status = CastVoteStatus::Valid.to_string(); - let total_distinct_voters_statement = transaction - .prepare( - r#" - SELECT - COUNT(DISTINCT voter_id_string) AS total_distinct_voters - FROM - sequent_backend.cast_vote - WHERE - tenant_id = $1 AND - election_event_id = $2 AND - status = $3; - "#, - ) - .await?; - - let rows: Vec = transaction - .query( - &total_distinct_voters_statement, - &[ - &parse_uuid_v4(tenant_id)?, - &parse_uuid_v4(election_event_id)?, - &status, - ], - ) - .await?; - - // all rows contain the count and if there's no rows well, count is clearly - // zero - let total_distinct_voters: i64 = if rows.len() == 0 { - 0 - } else { - rows[0].try_get::<&str, i64>("total_distinct_voters")? - }; - - Ok(total_distinct_voters) -} diff --git a/packages/windmill/src/services/election_statistics.rs b/packages/windmill/src/services/election_statistics.rs index 2d33ff9f899..62db407f1d7 100644 --- a/packages/windmill/src/services/election_statistics.rs +++ b/packages/windmill/src/services/election_statistics.rs @@ -1,7 +1,6 @@ // SPDX-FileCopyrightText: 2025 Sequent Tech Inc // // SPDX-License-Identifier: AGPL-3.0-only -use crate::services::cast_votes::CastVoteStatus; use anyhow::Result; use deadpool_postgres::Transaction; use sequent_core::services::uuid_validation::parse_uuid_v4; @@ -56,55 +55,6 @@ pub async fn update_election_statistics( Ok(()) } -#[instrument(skip(transaction), err)] -pub async fn get_count_distinct_voters( - transaction: &Transaction<'_>, - tenant_id: &str, - election_event_id: &str, - election_id: &str, -) -> Result { - let status = CastVoteStatus::Valid.to_string(); - let total_distinct_voters_statement = transaction - .prepare( - r#" - SELECT - COUNT(DISTINCT voter_id_string) AS total_distinct_voters - FROM - sequent_backend.election el - LEFT JOIN - sequent_backend.cast_vote cv ON el.id = cv.election_id - WHERE - el.tenant_id = $1 AND - el.election_event_id = $2 AND - el.id = $3 AND - cv.status = $4; - "#, - ) - .await?; - - let rows: Vec = transaction - .query( - &total_distinct_voters_statement, - &[ - &parse_uuid_v4(tenant_id)?, - &parse_uuid_v4(election_event_id)?, - &parse_uuid_v4(election_id)?, - &status, - ], - ) - .await?; - - // all rows contain the count and if there's no rows well, count is clearly - // zero - let total_distinct_voters: i64 = if rows.len() == 0 { - 0 - } else { - rows[0].try_get::<&str, i64>("total_distinct_voters")? - }; - - Ok(total_distinct_voters) -} - #[instrument(skip(transaction), err)] pub async fn get_count_areas( transaction: &Transaction<'_>, diff --git a/packages/windmill/src/services/electoral_log.rs b/packages/windmill/src/services/electoral_log.rs index 3e3fc974d06..c2237b4666d 100644 --- a/packages/windmill/src/services/electoral_log.rs +++ b/packages/windmill/src/services/electoral_log.rs @@ -325,13 +325,13 @@ impl ElectoralLog { voter_id: String, voter_username: Option, area_id: String, + voting_channel: String, ) -> Result<()> { let event = EventIdString(event_id.clone()); let election = ElectionIdString(election_id); let ip = VoterIpString(voter_ip); let country = VoterCountryString(voter_country); - - let message = Message::cast_vote_message( + let message = Message::cast_vote_with_channel_message( event, election, pseudonym_h, @@ -339,13 +339,14 @@ impl ElectoralLog { &self.sd, ip, country, + VotingChannelString(voting_channel), Some(voter_id.clone()), voter_username.clone(), area_id, )?; - let board_message: ElectoralLogMessage = (&message).try_into().with_context(|| { - "Error converting Message::cast_vote_message into ElectoralLogMessage" - })?; + let board_message: ElectoralLogMessage = (&message) + .try_into() + .with_context(|| "Error converting cast-vote Message into ElectoralLogMessage")?; let input = LogEventInput { election_event_id: event_id, message_type: LogMessageType::Internal, diff --git a/packages/windmill/src/services/insert_cast_vote.rs b/packages/windmill/src/services/insert_cast_vote.rs index 095b32d5712..c40665e7a7d 100644 --- a/packages/windmill/src/services/insert_cast_vote.rs +++ b/packages/windmill/src/services/insert_cast_vote.rs @@ -188,7 +188,7 @@ async fn insert_datafix_cast_vote_locked<'a>( voter_signature_data: &Option<(StrandSignaturePk, StrandSignature)>, is_early_voting_area: bool, initial_status: CastVoteStatus, -) -> Result { +) -> Result<(CastVote, VotingStatusChannel), CastVoteError> { let lock = PgLock::acquire( datafix_voter_lock_key(ids.tenant_id, ids.election_event_id, ids.voter_id), Uuid::new_v4().to_string(), @@ -489,7 +489,7 @@ pub async fn try_insert_cast_vote( .await; match result { - Ok(inserted_cast_vote) => { + Ok((inserted_cast_vote, effective_voting_channel)) => { let username = match username { Ok(username) => username, Err(err) => { @@ -545,6 +545,7 @@ pub async fn try_insert_cast_vote( voter_id.to_string(), username.clone(), area_id.to_string().clone(), + effective_voting_channel.to_string(), ) .await; if let Err(log_err) = log_result { @@ -744,7 +745,7 @@ pub async fn insert_cast_vote_and_commit<'a>( voter_signature_data: &Option<(StrandSignaturePk, StrandSignature)>, is_early_voting_area: bool, initial_status: CastVoteStatus, -) -> Result { +) -> Result<(CastVote, VotingStatusChannel), CastVoteError> { let election_id_string = input.election_id.to_string(); let election_id = election_id_string.as_str(); let tenant_uuid = parse_uuid_v4(ids.tenant_id) @@ -756,7 +757,7 @@ pub async fn insert_cast_vote_and_commit<'a>( .map_err(|e| CastVoteError::UuidParseFailed(e.to_string(), "election_id".to_string()))?; let area_uuid = parse_uuid_v4(ids.area_id) .map_err(|e| CastVoteError::UuidParseFailed(e.to_string(), "area_id".to_string()))?; - let (check_status, check_previous_votes) = try_join!( + let (effective_voting_channel, _check_previous_votes) = try_join!( // Check status is the most expensive call here, it takes around 2/3 of the time of the whole insert_cast_vote check_status( ids.tenant_id, @@ -801,6 +802,7 @@ pub async fn insert_cast_vote_and_commit<'a>( &ballot_signature, voter_ip, voter_country, + effective_voting_channel, initial_status, ); @@ -822,7 +824,7 @@ pub async fn insert_cast_vote_and_commit<'a>( .await .map_err(|e| CastVoteError::CommitFailed(e.to_string()))?; - Ok(cast_vote) + Ok((cast_vote, effective_voting_channel)) } pub(crate) fn hash_voter_id(voter_id: &str) -> Result { @@ -860,6 +862,149 @@ async fn get_electoral_log( Ok((electoral_log?, sk.clone())) } +fn effective_voting_channel_for_status( + voting_channel: VotingStatusChannel, + is_early_voting_area: bool, + election_status: &ElectionStatus, +) -> VotingStatusChannel { + let allow_early_voting = voting_channel == VotingStatusChannel::ONLINE + && is_early_voting_area + && election_status.status_by_channel(VotingStatusChannel::EARLY_VOTING) + == VotingStatus::OPEN + && election_status.status_by_channel(VotingStatusChannel::ONLINE) + == VotingStatus::NOT_STARTED; + + if allow_early_voting { + VotingStatusChannel::EARLY_VOTING + } else { + voting_channel + } +} + +/// Applies the existing vote-acceptance policy after `check_status` has loaded +/// the election state. The requested channel continues to drive status, date, +/// and grace-period checks; the effective channel is derived only after the +/// vote has passed those checks so channel persistence cannot broaden access. +fn check_status_with_loaded_election( + now: DateTime, + auth_time_local: DateTime, + voting_channel: VotingStatusChannel, + is_early_voting_area: bool, + mut dates: VotingPeriodDates, + election_status: &ElectionStatus, + election_presentation: &ElectionPresentation, + election_id: &str, +) -> Result { + if voting_channel != VotingStatusChannel::ONLINE { + dates.end_date = None; + } + + let close_date_esq_event_opt: Option> = + if let Some(end_date_str) = dates.end_date { + match ISO8601::to_date(&end_date_str) { + Ok(close_date) => { + info!("Parsed end_date: {}", close_date); + Some(close_date) + } + Err(err) => { + info!("Failed to parse end_date: {}", err); + None + } + } + } else { + None + }; + + let current_voting_status = election_status.status_by_channel(voting_channel); + let dates_by_channel = election_status.dates_by_channel(voting_channel); + let grace_period_secs = election_presentation.grace_period_secs.unwrap_or(0); + let grace_period_policy = election_presentation + .grace_period_policy + .clone() + .unwrap_or(EGracePeriodPolicy::NO_GRACE_PERIOD); + let apply_grace_period = grace_period_policy != EGracePeriodPolicy::NO_GRACE_PERIOD + && voting_channel == VotingStatusChannel::ONLINE + && current_voting_status != VotingStatus::PAUSED; + let grace_period_duration = Duration::seconds(grace_period_secs as i64); + + if let Some(close_date_esq_event) = close_date_esq_event_opt { + let close_date_plus_grace_period = close_date_esq_event + grace_period_duration; + + if apply_grace_period { + if now > close_date_plus_grace_period || auth_time_local > close_date_esq_event { + return Err(CastVoteError::CheckStatusFailed( + "Cannot vote outside grace period".to_string(), + )); + } + + if now <= close_date_esq_event && current_voting_status != VotingStatus::OPEN { + return Err(CastVoteError::CheckStatusFailed( + format!("Election voting status is not open (={current_voting_status:?}) while voting before the closing date of the election"), + )); + } + } else { + if now > close_date_esq_event { + return Err(CastVoteError::CheckStatusFailed( + "Election close date passed and grace period does not apply or is not set" + .to_string(), + )); + } + + if current_voting_status != VotingStatus::OPEN { + return Err(CastVoteError::CheckStatusFailed(format!( + "Election Voting Status for voting_channel={voting_channel:?} is {current_voting_status:?} instead of Open and grace_period_policy does not apply or is not set" + ))); + } + } + } else { + // Preserve the pre-ticket acceptance rule: this exception is only + // consulted when there is no configured online close date. + let allow_early_voting = is_early_voting_area + && election_status.status_by_channel(VotingStatusChannel::EARLY_VOTING) + == VotingStatus::OPEN + && election_status.status_by_channel(VotingStatusChannel::ONLINE) + == VotingStatus::NOT_STARTED; + let last_stopped_at = dates_by_channel + .last_stopped_at + .map(|val| val.with_timezone(&Local)); + let allow_grace_period_voting = match last_stopped_at { + Some(close_date) => { + apply_grace_period + && now < close_date + grace_period_duration + && auth_time_local < close_date + } + None => false, + }; + + match current_voting_status { + VotingStatus::NOT_STARTED if allow_early_voting => {} + VotingStatus::NOT_STARTED | VotingStatus::PAUSED => { + return Err(CastVoteError::CheckStatusFailed(format!( + "Voting Status for voting_channel={voting_channel:?} is {current_voting_status:?}" + ))); + } + VotingStatus::OPEN => { + debug!("Allowing cast vote for election id {election_id}"); + } + VotingStatus::CLOSED if allow_grace_period_voting => { + info!("Allowing grace period vote at {now}"); + } + VotingStatus::CLOSED => { + return Err(CastVoteError::CheckStatusFailed(format!( + "Voting Status for voting_channel={voting_channel:?} is {current_voting_status:?}" + ))); + } + } + } + + let effective_voting_channel = + effective_voting_channel_for_status(voting_channel, is_early_voting_area, election_status); + if effective_voting_channel != voting_channel { + debug!("Allowing early voting for election id {election_id}"); + } + Ok(effective_voting_channel) +} + #[instrument(skip_all, err)] async fn check_status( tenant_id: &str, @@ -870,7 +1015,7 @@ async fn check_status( auth_time: &Option, voting_channel: VotingStatusChannel, is_early_voting_area: bool, -) -> Result<(), CastVoteError> { +) -> Result { if election_event.is_archived { return Err(CastVoteError::CheckStatusFailed( "Election event is archived".to_string(), @@ -922,7 +1067,7 @@ async fn check_status( // these dates are used to check by scheduled event date // (even if the even hasn't been executed) - let mut dates: VotingPeriodDates = generate_voting_period_dates( + let dates: VotingPeriodDates = generate_voting_period_dates( scheduled_events.clone(), &tenant_id, &election_event_id, @@ -930,26 +1075,6 @@ async fn check_status( ) .unwrap_or(Default::default()); - if VotingStatusChannel::ONLINE != voting_channel.clone() { - dates.end_date = None; - } - - let close_date_esq_event_opt: Option> = - if let Some(end_date_str) = dates.end_date { - match ISO8601::to_date(&end_date_str) { - Ok(close_date) => { - info!("Parsed end_date: {}", close_date); - Some(close_date) - } - Err(err) => { - info!("Failed to parse end_date: {}", err); - None - } - } - } else { - None - }; - let election_status: ElectionStatus = election .status .clone() @@ -976,107 +1101,16 @@ async fn check_status( ))); } - let current_voting_status = election_status.status_by_channel(voting_channel); - let dates_by_channel = election_status.dates_by_channel(voting_channel); - - // calculate if we need to apply the grace period - let grace_period_secs = election_presentation.grace_period_secs.unwrap_or(0); - let grace_period_policy = election_presentation - .grace_period_policy - .unwrap_or(EGracePeriodPolicy::NO_GRACE_PERIOD); - - // We only apply the grace period if: - // 1. Grace period policy is not NO_GRACE_PERIOD - // 2. Voting Channel is ONLINE - // 3. Current Voting Status is not PAUSED - let apply_grace_period: bool = grace_period_policy != EGracePeriodPolicy::NO_GRACE_PERIOD - && voting_channel == VotingStatusChannel::ONLINE - && current_voting_status != VotingStatus::PAUSED; - let grace_period_duration = Duration::seconds(grace_period_secs as i64); - - // We can only calculate grace period if there's a close date - if let Some(close_date_esq_event) = close_date_esq_event_opt { - let close_date_plus_grace_period = close_date_esq_event + grace_period_duration; - - if apply_grace_period { - // a voter cannot cast a vote after the grace period or if the voter - // authenticated after the closing date - if now > close_date_plus_grace_period || auth_time_local > close_date_esq_event { - return Err(CastVoteError::CheckStatusFailed( - "Cannot vote outside grace period".to_string(), - )); - } - - // if voting before the closing date, we don't apply the grace - // period so current voting status needs to be open - if now <= close_date_esq_event && current_voting_status != VotingStatus::OPEN { - return Err(CastVoteError::CheckStatusFailed( - format!("Election voting status is not open (={current_voting_status:?}) while voting before the closing date of the election"), - )); - } - } else { - // if grace period does not apply and there's a closing date, to - // cast a vote you need to do it before the closing date - if now > close_date_esq_event { - return Err(CastVoteError::CheckStatusFailed( - "Election close date passed and grace period does not apply or is not set" - .to_string(), - )); - } - - // if no grace period, election needs to be open to cast a vote - // period - if current_voting_status != VotingStatus::OPEN { - return Err(CastVoteError::CheckStatusFailed( - format!("Election Voting Status for voting_channel={voting_channel:?} is {current_voting_status:?} instead of Open and grace_period_policy does not apply or is not set"), - )); - } - } - - // if there's no closing date, election needs to be open to cast a vote - } else { - let allow_early_voting = is_early_voting_area - && election_status.status_by_channel(VotingStatusChannel::EARLY_VOTING) - == VotingStatus::OPEN - && election_status.status_by_channel(VotingStatusChannel::ONLINE) - == VotingStatus::NOT_STARTED; - - let last_stopped_at = dates_by_channel - .last_stopped_at - .map(|val| val.with_timezone(&Local)); - - let allow_grace_period_voting = match last_stopped_at { - Some(close_date) => { - apply_grace_period - && (now < (close_date + grace_period_duration)) - && auth_time_local < close_date - } - None => false, - }; - - match current_voting_status { - VotingStatus::NOT_STARTED if allow_early_voting => { - debug!("Allowing early voting for election id {election_id}"); - } - VotingStatus::NOT_STARTED | VotingStatus::PAUSED => { - return Err(CastVoteError::CheckStatusFailed( - format!("Voting Status for voting_channel={voting_channel:?} is {current_voting_status:?}"), - )); - } - VotingStatus::OPEN => { - debug!("Allowing cast vote for election id {election_id}"); - } - VotingStatus::CLOSED if allow_grace_period_voting => { - info!("Allowing grace period vote at {now}"); - } - VotingStatus::CLOSED => { - return Err(CastVoteError::CheckStatusFailed( - format!("Voting Status for voting_channel={voting_channel:?} is {current_voting_status:?}"), - )); - } - }; - } - Ok(()) + check_status_with_loaded_election( + now, + auth_time_local, + voting_channel, + is_early_voting_area, + dates, + &election_status, + &election_presentation, + election_id, + ) } #[instrument(skip_all, err)] @@ -1231,4 +1265,93 @@ mod tests { Err(CastVoteError::InvalidDatafixConfiguration(_)) )); } + + #[test] + fn online_votes_in_open_early_voting_areas_use_early_voting_channel() { + let election_status = ElectionStatus { + voting_status: VotingStatus::NOT_STARTED, + early_voting_status: VotingStatus::OPEN, + ..Default::default() + }; + + assert_eq!( + effective_voting_channel_for_status( + VotingStatusChannel::ONLINE, + true, + &election_status, + ), + VotingStatusChannel::EARLY_VOTING + ); + } + + #[test] + fn online_close_date_keeps_existing_status_rejection_for_early_voting_area() { + let election_status = ElectionStatus { + voting_status: VotingStatus::NOT_STARTED, + early_voting_status: VotingStatus::OPEN, + ..Default::default() + }; + let now = ISO8601::to_date("2026-01-01T12:00:00Z").unwrap(); + let auth_time = ISO8601::to_date("2026-01-01T11:00:00Z").unwrap(); + let dates = VotingPeriodDates { + start_date: None, + end_date: Some("2026-01-02T00:00:00Z".to_string()), + }; + + let result = check_status_with_loaded_election( + now, + auth_time, + VotingStatusChannel::ONLINE, + true, + dates, + &election_status, + &ElectionPresentation::default(), + "election-id", + ); + + assert!(matches!(result, Err(CastVoteError::CheckStatusFailed(_)))); + } + + #[test] + fn accepted_early_vote_without_online_close_date_is_labelled_early_voting() { + let election_status = ElectionStatus { + voting_status: VotingStatus::NOT_STARTED, + early_voting_status: VotingStatus::OPEN, + ..Default::default() + }; + let now = ISO8601::to_date("2026-01-01T12:00:00Z").unwrap(); + let auth_time = ISO8601::to_date("2026-01-01T11:00:00Z").unwrap(); + + let channel = check_status_with_loaded_election( + now, + auth_time, + VotingStatusChannel::ONLINE, + true, + VotingPeriodDates::default(), + &election_status, + &ElectionPresentation::default(), + "election-id", + ) + .unwrap(); + + assert_eq!(channel, VotingStatusChannel::EARLY_VOTING); + } + + #[test] + fn early_voting_area_does_not_overwrite_transport_channels() { + let election_status = ElectionStatus { + voting_status: VotingStatus::NOT_STARTED, + kiosk_voting_status: VotingStatus::NOT_STARTED, + early_voting_status: VotingStatus::OPEN, + telephone_voting_status: VotingStatus::NOT_STARTED, + ..Default::default() + }; + + for channel in [VotingStatusChannel::KIOSK, VotingStatusChannel::TELEPHONE] { + assert_eq!( + effective_voting_channel_for_status(channel, true, &election_status,), + channel + ); + } + } } diff --git a/packages/yarn.lock b/packages/yarn.lock index e909f8e2cc5..76297d3bf5c 100644 --- a/packages/yarn.lock +++ b/packages/yarn.lock @@ -17502,15 +17502,15 @@ sentence-case@^3.0.4: "sequent-core@file:./admin-portal/rust/sequent-core-0.1.0.tgz": version "0.1.0" - resolved "file:./admin-portal/rust/sequent-core-0.1.0.tgz#72e87d0083e6a2a12d1c5494f64ef79a3f5d6221" + resolved "file:./admin-portal/rust/sequent-core-0.1.0.tgz#03e2dc17cc0159961a895d7b11386c218f624faf" "sequent-core@file:./ballot-verifier/rust/sequent-core-0.1.0.tgz": version "0.1.0" - resolved "file:./ballot-verifier/rust/sequent-core-0.1.0.tgz#72e87d0083e6a2a12d1c5494f64ef79a3f5d6221" + resolved "file:./ballot-verifier/rust/sequent-core-0.1.0.tgz#03e2dc17cc0159961a895d7b11386c218f624faf" "sequent-core@file:./voting-portal/rust/sequent-core-0.1.0.tgz": version "0.1.0" - resolved "file:./voting-portal/rust/sequent-core-0.1.0.tgz#72e87d0083e6a2a12d1c5494f64ef79a3f5d6221" + resolved "file:./voting-portal/rust/sequent-core-0.1.0.tgz#03e2dc17cc0159961a895d7b11386c218f624faf" serialize-javascript@^6.0.0, serialize-javascript@^6.0.2: version "6.0.2"