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 c1f7606bd48..5ead976bbb8 100644 --- a/hasura/metadata/actions.graphql +++ b/hasura/metadata/actions.graphql @@ -1148,11 +1148,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! @@ -1161,6 +1168,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 f9b101ca478..317dc787ffe 100644 --- a/hasura/metadata/actions.yaml +++ b/hasura/metadata/actions.yaml @@ -1752,6 +1752,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 7472612b9e2..29ce23d6387 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", @@ -180116,4 +180224,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 cf9bd112650..52f3e79c90b 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 e9e4dd56cb8..6ecadaa4447 100644 --- a/packages/admin-portal/src/gql/gql.ts +++ b/packages/admin-portal/src/gql/gql.ts @@ -72,12 +72,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, @@ -200,12 +200,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, @@ -519,7 +519,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. */ @@ -539,7 +539,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. */ @@ -793,4 +793,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 df2649fca46..1141d33f638 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']; @@ -25507,7 +25516,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']; @@ -25552,7 +25561,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; }>; @@ -26189,12 +26198,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; @@ -26256,4 +26265,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 49e4cdecd3c..e3b8d034350 100644 --- a/packages/admin-portal/src/translations/cat.ts +++ b/packages/admin-portal/src/translations/cat.ts @@ -1537,6 +1537,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 13f21260f4a..d9c4c5e182b 100644 --- a/packages/admin-portal/src/translations/en.ts +++ b/packages/admin-portal/src/translations/en.ts @@ -1513,6 +1513,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 6b82fb4c023..423a48c6295 100644 --- a/packages/admin-portal/src/translations/es.ts +++ b/packages/admin-portal/src/translations/es.ts @@ -1527,6 +1527,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 43cdf3480d3..3c267f64feb 100644 --- a/packages/admin-portal/src/translations/eu.ts +++ b/packages/admin-portal/src/translations/eu.ts @@ -1523,6 +1523,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 293b27c2d7c..7d809d28979 100644 --- a/packages/admin-portal/src/translations/fr.ts +++ b/packages/admin-portal/src/translations/fr.ts @@ -1534,6 +1534,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 894c67d333f..10f65a0e16a 100644 --- a/packages/admin-portal/src/translations/gl.ts +++ b/packages/admin-portal/src/translations/gl.ts @@ -1527,6 +1527,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 ebae5e560da..4981810c5bb 100644 --- a/packages/admin-portal/src/translations/nl.ts +++ b/packages/admin-portal/src/translations/nl.ts @@ -1524,6 +1524,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 ddc7f4f345e..fa734a150aa 100644 --- a/packages/admin-portal/src/translations/tl.ts +++ b/packages/admin-portal/src/translations/tl.ts @@ -1528,6 +1528,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 cf9bd112650..52f3e79c90b 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 5e8a47c52d2..56d409a0dc1 100644 --- a/packages/electoral-log/src/messages/message.rs +++ b/packages/electoral-log/src/messages/message.rs @@ -147,6 +147,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() @@ -594,6 +649,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, @@ -601,7 +663,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(), @@ -681,4 +743,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 5004549e9df..f6cd89dc9af 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, @@ -408,13 +415,10 @@ pub enum StatementBody { /// `ChangesApplied` entry — there is exactly one entry per phase per run, /// not one per voter. /// - /// Borsh discriminant note for release/10.0: this variant is discriminant - /// 27 here, whereas on `main` it is 28 because `CastVoteWithChannel` - /// occupies 27 there. When the `CastVoteWithChannel` topic is ported to - /// this branch, append it AFTER this variant (as discriminant 28) — do - /// not insert it before to match `main`'s order, as that would shift this - /// variant and break decoding of statements already written by this - /// branch. The resulting cross-branch order mismatch with `main` is the + /// Borsh discriminant note for release/10.0: this variant remains at + /// discriminant 27. `CastVoteWithChannel` is appended after it rather than + /// inserted before it, so statements already written by this branch remain + /// decodable. The resulting cross-branch order mismatch with `main` is the /// accepted trade-off. ExternalReconciliation( EventIdString, @@ -424,6 +428,21 @@ pub enum StatementBody { ExternalReconciliationInputHashString, ExternalReconciliationOutputHashString, ), + /// 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. @@ -467,7 +486,7 @@ pub enum StatementEventType { } #[cfg(test)] -mod tests { +mod statement_compatibility_tests { use super::*; fn external_api_request_description(operation: &str) -> String { @@ -541,6 +560,14 @@ mod tests { ExternalReconciliationInputHashString(String::new()), ExternalReconciliationOutputHashString(None), ); + 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); @@ -550,6 +577,23 @@ mod tests { // rebase that introduced `ExternalApiRequest`. assert_eq!(borsh::to_vec(&external).unwrap()[0], 26); assert_eq!(borsh::to_vec(&external_reconciliation).unwrap()[0], 27); + assert_eq!(borsh::to_vec(&cast_vote_with_channel).unwrap()[0], 28); + } + + #[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] @@ -567,9 +611,17 @@ 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 + ); + assert_eq!( + borsh::to_vec(&StatementType::ExternalReconciliation).unwrap()[0], + 28 + ); } #[test] @@ -609,7 +661,7 @@ pub enum StatementLogType { } #[cfg(test)] -mod tests { +mod results_publication_tests { use super::*; #[test] diff --git a/packages/harvest/src/main.rs b/packages/harvest/src/main.rs index 87744aca461..b2b8f47e4f0 100644 --- a/packages/harvest/src/main.rs +++ b/packages/harvest/src/main.rs @@ -124,8 +124,8 @@ async fn rocket() -> _ { routes::tally_sheets::preview_tally_sheet_import, routes::tally_sheets::create_tally_sheet_import, routes::tally_sheets::review_tally_sheet_import, - routes::external_reconciliation::create_reconciliation_import, - routes::external_reconciliation::apply_reconciliation_changes, + routes::datafix_reconciliation::create_reconciliation_import, + routes::datafix_reconciliation::apply_reconciliation_changes, routes::create_ballot_receipt::create_ballot_receipt, routes::election_dates::manage_election_dates, routes::custom_urls::update_custom_url, diff --git a/packages/harvest/src/routes/api_datafix.rs b/packages/harvest/src/routes/api_datafix.rs index 3cb30cc4d4b..b41ef4fc697 100644 --- a/packages/harvest/src/routes/api_datafix.rs +++ b/packages/harvest/src/routes/api_datafix.rs @@ -13,14 +13,14 @@ use serde::Serialize; use tracing::{error, instrument}; use windmill::services; use windmill::services::database::{get_hasura_pool, get_keycloak_pool}; -use windmill::services::external::api_datafix::{ +use windmill::services::datafix::api_datafix::{ acquire_inbound_voter_lock, audit_inbound_operation, audit_inbound_operation_standalone, ensure_inbound_reenable_is_safe, ensure_voter_has_no_active_vote, release_inbound_voter_lock, valid_inbound_voting_channel, InboundVoterLock, }; -use windmill::services::external::datafix_types::*; -use windmill::services::external::utils::get_event_id_and_datafix_annotations; +use windmill::services::datafix::types::*; +use windmill::services::datafix::utils::get_event_id_and_datafix_annotations; #[instrument(skip_all)] #[post("/add-voter", format = "json", data = "")] @@ -61,7 +61,7 @@ pub async fn add_voter( .await?; let realm = get_event_realm(&claims.tenant_id, &election_event_id); - let result = services::external::api_datafix::add_datafix_voter( + let result = services::datafix::api_datafix::add_datafix_voter( &hasura_transaction, &claims.tenant_id, &claims.datafix_event_id, @@ -212,7 +212,7 @@ pub async fn update_voter( release_inbound_voter_lock(lock).await; return Err(err); } - let result = services::external::api_datafix::update_datafix_voter( + let result = services::datafix::api_datafix::update_datafix_voter( &hasura_transaction, &keycloak_transaction, &claims.tenant_id, @@ -353,7 +353,7 @@ pub async fn delete_voter( release_inbound_voter_lock(lock).await; return Err(err); } - let result = services::external::api_datafix::disable_datafix_voter( + let result = services::datafix::api_datafix::disable_datafix_voter( &hasura_transaction, &keycloak_transaction, &claims.tenant_id, @@ -488,7 +488,7 @@ pub async fn unmark_voted( release_inbound_voter_lock(lock).await; return Err(err); } - let result = services::external::api_datafix::unmark_voter_as_voted( + let result = services::datafix::api_datafix::unmark_voter_as_voted( &hasura_transaction, &keycloak_transaction, &claims.tenant_id, @@ -634,7 +634,7 @@ pub async fn mark_voted( release_inbound_voter_lock(lock).await; return Err(err); } - let result = services::external::api_datafix::mark_as_voted_via_channel( + let result = services::datafix::api_datafix::mark_as_voted_via_channel( &hasura_transaction, &keycloak_transaction, &claims.tenant_id, @@ -756,7 +756,7 @@ pub async fn replace_pin( let hasura_transaction = transaction_result.expect("transaction result was checked above"); - let result = services::external::api_datafix::replace_voter_pin( + let result = services::datafix::api_datafix::replace_voter_pin( &hasura_transaction, &keycloak_transaction, &claims.tenant_id, diff --git a/packages/harvest/src/routes/external_reconciliation.rs b/packages/harvest/src/routes/datafix_reconciliation.rs similarity index 98% rename from packages/harvest/src/routes/external_reconciliation.rs rename to packages/harvest/src/routes/datafix_reconciliation.rs index a94962e44b1..b8b4b1e2458 100644 --- a/packages/harvest/src/routes/external_reconciliation.rs +++ b/packages/harvest/src/routes/datafix_reconciliation.rs @@ -27,9 +27,9 @@ use windmill::postgres::election_event::{ use windmill::services::celery_app::get_celery_app; use windmill::services::consolidation::eml_generator::ValidateAnnotations; use windmill::services::database::get_hasura_pool; +use windmill::services::datafix::reconciliation::diff::ReconciliationApplyEnvelope; +use windmill::services::datafix::reconciliation::types::ReconciliationPatchSource; use windmill::services::documents::get_document_as_temp_file; -use windmill::services::external::reconciliation::diff::ReconciliationApplyEnvelope; -use windmill::services::external::types::ReconciliationPatchSource; use windmill::services::tasks_execution::{ post as post_task_execution, update_fail, }; 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/harvest/src/routes/error_catchers.rs b/packages/harvest/src/routes/error_catchers.rs index 1bbaf4b879a..08b345865cd 100644 --- a/packages/harvest/src/routes/error_catchers.rs +++ b/packages/harvest/src/routes/error_catchers.rs @@ -6,7 +6,7 @@ use rocket::serde::json::Json; use rocket::Request; use serde::{Deserialize, Serialize}; use tracing::instrument; -use windmill::services::external::datafix_types::{ +use windmill::services::datafix::types::{ DatafixErrorCode, DatafixResponse, JsonErrorResponse, }; diff --git a/packages/harvest/src/routes/mod.rs b/packages/harvest/src/routes/mod.rs index bf3991eeaba..da3a1851ab5 100644 --- a/packages/harvest/src/routes/mod.rs +++ b/packages/harvest/src/routes/mod.rs @@ -8,6 +8,7 @@ pub mod ballot_publication; pub mod ballot_publication_prepare_preview; pub mod create_ballot_receipt; pub mod custom_urls; +pub mod datafix_reconciliation; pub mod delete_certificate_authority; pub mod delete_election_event; pub mod election_dates; @@ -25,7 +26,6 @@ pub mod export_tally_results; pub mod export_tasks_execution; pub mod export_template; pub mod export_tenant_config; -pub mod external_reconciliation; pub mod fetch_document; pub mod generate_preview_url; pub mod get_certificate_authorities_pem; diff --git a/packages/harvest/src/routes/users.rs b/packages/harvest/src/routes/users.rs index 8f3c4b6c5c2..1d5450bd2b2 100644 --- a/packages/harvest/src/routes/users.rs +++ b/packages/harvest/src/routes/users.rs @@ -27,10 +27,10 @@ use windmill::postgres::election_event::get_election_event_by_id; use windmill::services::cast_votes::get_users_with_vote_info; use windmill::services::celery_app::get_celery_app; use windmill::services::database::{get_hasura_pool, get_keycloak_pool}; +use windmill::services::datafix::utils::datafix_annotations; use windmill::services::export::export_users::{ ExportBody, ExportTenantUsersBody, ExportUsersBody, }; -use windmill::services::external::utils::datafix_annotations; use windmill::services::keycloak_events::list_keycloak_events_by_type; use windmill::services::tasks_execution::*; use windmill::services::users::list_users_has_voted; 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 cf9bd112650..52f3e79c90b 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 cf9bd112650..52f3e79c90b 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 d4a4b4d387d..21966441451 100644 --- a/packages/windmill/src/postgres/cast_vote.rs +++ b/packages/windmill/src/postgres/cast_vote.rs @@ -4,14 +4,134 @@ 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 std::collections::HashMap; 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<'_>, @@ -25,6 +145,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(); @@ -67,10 +188,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( @@ -103,6 +221,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 a464049e23a..edd98bdf43c 100644 --- a/packages/windmill/src/services/cast_votes.rs +++ b/packages/windmill/src/services/cast_votes.rs @@ -3,15 +3,19 @@ // SPDX-License-Identifier: AGPL-3.0-only use super::database::PgConfig; use super::sql_utils::escape_sql_literal; -use crate::services::electoral_log::ElectoralLog; -use crate::services::external::utils::{ +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, }; +use crate::services::electoral_log::ElectoralLog; use anyhow::{anyhow, Context, Result}; 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/external/api_datafix.rs b/packages/windmill/src/services/datafix/api_datafix.rs similarity index 99% rename from packages/windmill/src/services/external/api_datafix.rs rename to packages/windmill/src/services/datafix/api_datafix.rs index 582972a1d40..3f582d0abf0 100644 --- a/packages/windmill/src/services/external/api_datafix.rs +++ b/packages/windmill/src/services/datafix/api_datafix.rs @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2025 Sequent Tech Inc // // SPDX-License-Identifier: AGPL-3.0-only -use super::datafix_types::*; +use super::types::*; use super::utils::*; use crate::postgres::cast_vote::{get_voter_cast_vote_state, VoterCastVoteState}; @@ -435,7 +435,7 @@ pub async fn acquire_inbound_voter_lock( let user_id = get_user_id(keycloak_transaction, &realm, username).await?; let user_id_uuid = parse_uuid_v4(&user_id) .map_err(|_| DatafixResponse::error(DatafixErrorCode::InternalError))?; - let lock_key = external_voter_lock_key(&claims.tenant_id, &election_event_id, &user_id_uuid); + let lock_key = datafix_voter_lock_key(&claims.tenant_id, &election_event_id, &user_id_uuid); let lock = PgLock::acquire( lock_key, Uuid::new_v4().to_string(), @@ -672,7 +672,7 @@ mod tests { valid_inbound_voting_channel, }; use crate::postgres::cast_vote::VoterCastVoteState; - use crate::services::external::datafix_types::DatafixErrorCode; + use crate::services::datafix::types::DatafixErrorCode; use keycloak::KeycloakError; use rocket::http::Status; use sequent_core::types::keycloak::{ diff --git a/packages/windmill/src/services/external/mod.rs b/packages/windmill/src/services/datafix/mod.rs similarity index 90% rename from packages/windmill/src/services/external/mod.rs rename to packages/windmill/src/services/datafix/mod.rs index 36077223647..45b5d857c67 100644 --- a/packages/windmill/src/services/external/mod.rs +++ b/packages/windmill/src/services/datafix/mod.rs @@ -3,7 +3,6 @@ // SPDX-License-Identifier: AGPL-3.0-only pub mod api_datafix; -pub mod datafix_types; pub mod reconciliation; pub mod types; pub mod utils; diff --git a/packages/windmill/src/services/external/reconciliation/apply.rs b/packages/windmill/src/services/datafix/reconciliation/apply.rs similarity index 96% rename from packages/windmill/src/services/external/reconciliation/apply.rs rename to packages/windmill/src/services/datafix/reconciliation/apply.rs index 1664767dbdd..5bde629f290 100644 --- a/packages/windmill/src/services/external/reconciliation/apply.rs +++ b/packages/windmill/src/services/datafix/reconciliation/apply.rs @@ -25,10 +25,12 @@ use crate::postgres::area::{get_area_by_id, get_area_id_from_event_by_name}; use crate::postgres::cast_vote::get_voter_cast_vote_state; -use crate::services::external::reconciliation::diff::DiffItem; -use crate::services::external::types::{ReconciliationChangeCategory, SequentReconciliationField}; -use crate::services::external::utils::{ - external_voter_lock_key, voted_via_internet, voted_via_not_internet_channel, +use crate::services::datafix::reconciliation::diff::DiffItem; +use crate::services::datafix::reconciliation::types::{ + ReconciliationChangeCategory, SequentReconciliationField, +}; +use crate::services::datafix::utils::{ + datafix_voter_lock_key, voted_via_internet, voted_via_not_internet_channel, DATAFIX_VOTER_LOCK_SECS, }; use crate::services::pg_lock::PgLock; @@ -76,7 +78,7 @@ pub async fn apply_voter_changes( }; let lock = PgLock::acquire( - external_voter_lock_key(tenant_id, election_event_id, &user_id), + datafix_voter_lock_key(tenant_id, election_event_id, &user_id), Uuid::new_v4().to_string(), ISO8601::now() + chrono::Duration::seconds(DATAFIX_VOTER_LOCK_SECS), ) @@ -197,7 +199,7 @@ async fn apply_voter_changes_locked( /// `enabled` transition and Keycloak attributes `items` carry, and write them /// in a single `edit_user` call. Has no notion of *why* — that judgment /// belongs entirely to whichever origin produced the diff (see -/// `SequentReconciliationField` in `services::external::types`). +/// `SequentReconciliationField` in `services::datafix::reconciliation::types`). #[instrument(skip(hasura_transaction, items), err)] async fn apply_generic_voter_edit( hasura_transaction: &Transaction<'_>, @@ -384,7 +386,7 @@ async fn resolve_area_attribute( #[cfg(test)] mod tests { use super::*; - use crate::services::external::types::ReconciliationPatchTarget; + use crate::services::datafix::reconciliation::types::ReconciliationPatchTarget; use sequent_core::types::keycloak::{DATE_OF_BIRTH, DISABLE_COMMENT}; fn item(field: SequentReconciliationField) -> DiffItem { diff --git a/packages/windmill/src/services/external/reconciliation/bulk_create.rs b/packages/windmill/src/services/datafix/reconciliation/bulk_create.rs similarity index 99% rename from packages/windmill/src/services/external/reconciliation/bulk_create.rs rename to packages/windmill/src/services/datafix/reconciliation/bulk_create.rs index 9f7c34f8e02..4c3cbd99cfb 100644 --- a/packages/windmill/src/services/external/reconciliation/bulk_create.rs +++ b/packages/windmill/src/services/datafix/reconciliation/bulk_create.rs @@ -24,7 +24,7 @@ //! listens for Keycloak admin events fires for these voters. use crate::postgres::keycloak_realm::get_realm_id; -use crate::services::external::reconciliation::diff::DiffItem; +use crate::services::datafix::reconciliation::diff::DiffItem; use anyhow::{anyhow, Context, Result}; use deadpool_postgres::Transaction; use sequent_core::types::keycloak::{ @@ -421,7 +421,7 @@ async fn insert_voter_batch( #[cfg(test)] mod tests { use super::*; - use crate::services::external::types::{ + use crate::services::datafix::reconciliation::types::{ ReconciliationChangeCategory, ReconciliationPatchTarget, SequentReconciliationField, }; use sequent_core::types::keycloak::{ATTR_RESET_VALUE, DISABLE_REASON_MARKVOTED_CALL}; diff --git a/packages/windmill/src/services/external/reconciliation/csv.rs b/packages/windmill/src/services/datafix/reconciliation/csv.rs similarity index 98% rename from packages/windmill/src/services/external/reconciliation/csv.rs rename to packages/windmill/src/services/datafix/reconciliation/csv.rs index d317f545ee8..40f90a41279 100644 --- a/packages/windmill/src/services/external/reconciliation/csv.rs +++ b/packages/windmill/src/services/datafix/reconciliation/csv.rs @@ -9,8 +9,8 @@ //! a 100k-row file; the caller decides whether accumulated errors should //! still block the whole import. -use crate::services::external::datafix_types::ParsedDatafixReconciliationRow; -use crate::services::external::types::ReconciliationFileMeta; +use crate::services::datafix::reconciliation::types::ReconciliationFileMeta; +use crate::services::datafix::types::ParsedDatafixReconciliationRow; use ::csv::{ReaderBuilder, StringRecord}; use sequent_core::types::keycloak::ATTR_RESET_VALUE; use std::collections::HashSet; diff --git a/packages/windmill/src/services/external/reconciliation/diff.rs b/packages/windmill/src/services/datafix/reconciliation/diff.rs similarity index 99% rename from packages/windmill/src/services/external/reconciliation/diff.rs rename to packages/windmill/src/services/datafix/reconciliation/diff.rs index aad54dc8b7a..fd0c8a0874a 100644 --- a/packages/windmill/src/services/external/reconciliation/diff.rs +++ b/packages/windmill/src/services/datafix/reconciliation/diff.rs @@ -2,14 +2,14 @@ // // SPDX-License-Identifier: AGPL-3.0-only -use crate::services::external::datafix_types::{ - channels_equal, file_channel_to_keycloak, keycloak_channel_to_file, DatafixReconciliationField, - ParsedDatafixReconciliationRow, FILE_CHANNEL_INTERNET, -}; -use crate::services::external::types::{ +use crate::services::datafix::reconciliation::types::{ ReconciliationChangeCategory, ReconciliationPatchSource, ReconciliationPatchTarget, SequentReconciliationField, }; +use crate::services::datafix::types::{ + channels_equal, file_channel_to_keycloak, keycloak_channel_to_file, DatafixReconciliationField, + ParsedDatafixReconciliationRow, FILE_CHANNEL_INTERNET, +}; use crate::services::users::VoterSnapshot; use sequent_core::types::keycloak::{ ATTR_RESET_VALUE, DATE_OF_BIRTH, DISABLE_COMMENT, DISABLE_REASON_DELETE_CALL, @@ -137,7 +137,7 @@ pub fn index_datafix_area_fields( } /// Runs the forward pass for one batch of file rows (see -/// `services::external::reconciliation::csv::ReconciliationRowBatches`): +/// `services::datafix::reconciliation::csv::ReconciliationRowBatches`): /// classifies each row against its Sequent snapshot, if this batch's /// `fetch_realm_voter_snapshots_by_usernames` call found one — /// `snapshots_by_username` holds only this batch's matches, not the whole @@ -734,8 +734,8 @@ fn row_failure(voter_username: &str, reason: &str) -> DiffItem { /// one place. #[instrument(skip_all)] fn composed_area_name(row: &ParsedDatafixReconciliationRow) -> String { - use crate::services::external::datafix_types::VoterInformationBody; - use crate::services::external::utils::compose_area_name; + use crate::services::datafix::types::VoterInformationBody; + use crate::services::datafix::utils::compose_area_name; // The file's own "no value" sentinel (`ATTR_RESET_VALUE`) must not be // concatenated into the composed name as a literal segment — translate it diff --git a/packages/windmill/src/services/external/reconciliation/mod.rs b/packages/windmill/src/services/datafix/reconciliation/mod.rs similarity index 88% rename from packages/windmill/src/services/external/reconciliation/mod.rs rename to packages/windmill/src/services/datafix/reconciliation/mod.rs index 244bd22fc6f..0b9cc762be7 100644 --- a/packages/windmill/src/services/external/reconciliation/mod.rs +++ b/packages/windmill/src/services/datafix/reconciliation/mod.rs @@ -8,7 +8,7 @@ //! Sequent-side changes directly. //! //! Reuses the existing Datafix API helpers throughout (per the spec's -//! explicit instruction to do so): `utils::external_voter_lock_key`/`PgLock`, +//! explicit instruction to do so): `utils::datafix_voter_lock_key`/`PgLock`, //! `utils::post_operation_result_to_electoral_log`, and //! `utils::compose_area_name`. The per-voter cast-vote guard is //! re-implemented rather than reused — see `apply`'s module doc. @@ -18,3 +18,4 @@ pub mod bulk_create; pub mod csv; pub mod diff; pub mod patch; +pub mod types; diff --git a/packages/windmill/src/services/external/reconciliation/patch.rs b/packages/windmill/src/services/datafix/reconciliation/patch.rs similarity index 98% rename from packages/windmill/src/services/external/reconciliation/patch.rs rename to packages/windmill/src/services/datafix/reconciliation/patch.rs index 79b2ede9fce..b97db6b36cd 100644 --- a/packages/windmill/src/services/external/reconciliation/patch.rs +++ b/packages/windmill/src/services/datafix/reconciliation/patch.rs @@ -10,11 +10,11 @@ //! `DatafixReconciliationField` present per voter regardless of which ones changed, //! per the "Patch Files Format" spec. -use crate::services::external::datafix_types::{ +use crate::services::datafix::reconciliation::diff::DiffItem; +use crate::services::datafix::reconciliation::types::ReconciliationPatchTarget; +use crate::services::datafix::types::{ DatafixReconciliationField, ParsedDatafixReconciliationRow, FILE_CHANNEL_INTERNET, }; -use crate::services::external::reconciliation::diff::DiffItem; -use crate::services::external::types::ReconciliationPatchTarget; use sequent_core::types::keycloak::ATTR_RESET_VALUE; use sha2::{Digest, Sha256}; use std::collections::HashMap; @@ -215,7 +215,7 @@ pub fn sha256_hex(content: &[u8]) -> String { #[cfg(test)] mod tests { use super::*; - use crate::services::external::types::{ + use crate::services::datafix::reconciliation::types::{ ReconciliationChangeCategory, SequentReconciliationField, }; diff --git a/packages/windmill/src/services/external/types.rs b/packages/windmill/src/services/datafix/reconciliation/types.rs similarity index 99% rename from packages/windmill/src/services/external/types.rs rename to packages/windmill/src/services/datafix/reconciliation/types.rs index 2e29d2f1041..dea5852ce4d 100644 --- a/packages/windmill/src/services/external/types.rs +++ b/packages/windmill/src/services/datafix/reconciliation/types.rs @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2026 Sequent Tech Inc // // SPDX-License-Identifier: AGPL-3.0-only -use super::datafix_types::DatafixReconciliationField; +use crate::services::datafix::types::DatafixReconciliationField; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use strum_macros::{Display, EnumString}; diff --git a/packages/windmill/src/services/external/datafix_types.rs b/packages/windmill/src/services/datafix/types.rs similarity index 100% rename from packages/windmill/src/services/external/datafix_types.rs rename to packages/windmill/src/services/datafix/types.rs diff --git a/packages/windmill/src/services/external/utils.rs b/packages/windmill/src/services/datafix/utils.rs similarity index 98% rename from packages/windmill/src/services/external/utils.rs rename to packages/windmill/src/services/datafix/utils.rs index 85c3862789f..34c6c9f2fc5 100644 --- a/packages/windmill/src/services/external/utils.rs +++ b/packages/windmill/src/services/datafix/utils.rs @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2025 Sequent Tech Inc // // SPDX-License-Identifier: AGPL-3.0-only -use super::datafix_types::*; +use super::types::*; use crate::postgres::area::get_event_areas; use crate::postgres::election_event::get_election_event_by_id; use crate::postgres::election_event::update_election_event_annotations; @@ -41,16 +41,12 @@ pub const DATAFIX_LAST_APPLY_HAD_FAILURES_KEY: &str = "datafix:last_apply_had_fa /// VoterView round-trip so the lock outlives an in-flight SOAP call. pub const DATAFIX_VOTER_LOCK_SECS: i64 = 300; -/// Advisory-lock key that serializes all external-voter-registry work for one +/// Advisory-lock key that serializes all Datafix work for one /// voter within an event — outbound `SetVoted`, disable-release, inbound /// mark/unmark, and reconciliation apply all take this same lock, so none of /// them can interleave for the same voter. #[instrument] -pub fn external_voter_lock_key( - tenant_id: &str, - election_event_id: &str, - voter_id: &Uuid, -) -> String { +pub fn datafix_voter_lock_key(tenant_id: &str, election_event_id: &str, voter_id: &Uuid) -> String { format!("datafix-voter-{tenant_id}-{election_event_id}-{voter_id}") } diff --git a/packages/windmill/src/services/external/voterview_requests.rs b/packages/windmill/src/services/datafix/voterview_requests.rs similarity index 99% rename from packages/windmill/src/services/external/voterview_requests.rs rename to packages/windmill/src/services/datafix/voterview_requests.rs index f49e9bd9bea..6afa1ee54f7 100644 --- a/packages/windmill/src/services/external/voterview_requests.rs +++ b/packages/windmill/src/services/datafix/voterview_requests.rs @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: AGPL-3.0-only -use super::datafix_types::{ +use super::types::{ DatafixAnnotations, SoapRequest, SoapRequestData, SoapRequestResponse, SoapRequestResult, }; use crate::postgres::election_event::ElectionEventDatafix; 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 e1ed4d007ff..211eb21cc3f 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, @@ -505,7 +506,7 @@ impl ElectoralLog { /// Posts a third-party voter registry reconciliation run event (patch /// generation or applying the Sequent-side diff) — see - /// `windmill::services::external::reconciliation`. Named for the general + /// `windmill::services::datafix::reconciliation`. Named for the general /// capability, not the specific integration (Datafix) that first needed /// it. `artifact` carries the JSON of old/new values applied, for a /// `ChangesApplied` entry (`None` for `PatchGenerated`, which has nothing diff --git a/packages/windmill/src/services/insert_cast_vote.rs b/packages/windmill/src/services/insert_cast_vote.rs index 0c78d9bbbf2..e01063ddb02 100644 --- a/packages/windmill/src/services/insert_cast_vote.rs +++ b/packages/windmill/src/services/insert_cast_vote.rs @@ -9,12 +9,11 @@ use crate::postgres::election_event::get_election_event_by_id; use crate::postgres::scheduled_event::find_scheduled_event_by_election_event_id; use crate::services::cast_votes::{CastVote, CastVoteStatus}; use crate::services::database::{get_hasura_pool, get_keycloak_pool}; +use crate::services::datafix::utils::{ + datafix_annotations, datafix_voter_lock_key, is_datafix_election_event, DATAFIX_VOTER_LOCK_SECS, +}; use crate::services::election_event_board::get_election_event_board; use crate::services::electoral_log::ElectoralLog; -use crate::services::external::utils::{ - datafix_annotations, external_voter_lock_key, is_datafix_election_event, - DATAFIX_VOTER_LOCK_SECS, -}; use crate::services::pg_lock::PgLock; use crate::services::protocol_manager::get_protocol_manager; use crate::services::users::get_username_by_id; @@ -189,11 +188,11 @@ 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 voter_id_uuid = parse_uuid_v4(ids.voter_id) .map_err(|err| CastVoteError::VoterStateLocked(format!("Invalid voter id: {err}")))?; let lock = PgLock::acquire( - external_voter_lock_key(ids.tenant_id, ids.election_event_id, &voter_id_uuid), + datafix_voter_lock_key(ids.tenant_id, ids.election_event_id, &voter_id_uuid), Uuid::new_v4().to_string(), ISO8601::now() + Duration::seconds(DATAFIX_VOTER_LOCK_SECS), ) @@ -492,7 +491,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) => { @@ -548,6 +547,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 { @@ -747,7 +747,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) @@ -759,7 +759,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, @@ -804,6 +804,7 @@ pub async fn insert_cast_vote_and_commit<'a>( &ballot_signature, voter_ip, voter_country, + effective_voting_channel, initial_status, ); @@ -825,7 +826,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 { @@ -863,6 +864,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, @@ -873,7 +1017,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(), @@ -925,7 +1069,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, @@ -933,26 +1077,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() @@ -979,107 +1103,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)] @@ -1234,4 +1267,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/windmill/src/services/mod.rs b/packages/windmill/src/services/mod.rs index 56e554dd16a..4dfa5cc0e97 100644 --- a/packages/windmill/src/services/mod.rs +++ b/packages/windmill/src/services/mod.rs @@ -13,6 +13,7 @@ pub mod compress; pub mod consolidation; pub mod custom_url; pub mod database; +pub mod datafix; pub mod delete_election_event; pub mod documents; pub mod election; @@ -26,7 +27,6 @@ pub mod electoral_log; pub mod ess_xml_converter; pub mod event_list; pub mod export; -pub mod external; pub mod folders; pub mod generate_preview_url; pub mod google_meet; diff --git a/packages/windmill/src/tasks/apply_reconciliation_patch.rs b/packages/windmill/src/tasks/apply_reconciliation_patch.rs index d6ebc9b9e92..bbb283968b1 100644 --- a/packages/windmill/src/tasks/apply_reconciliation_patch.rs +++ b/packages/windmill/src/tasks/apply_reconciliation_patch.rs @@ -16,14 +16,16 @@ use crate::postgres::document::get_document; use crate::postgres::election_event::{get_election_event_by_id, ElectionEventDatafix}; use crate::services::consolidation::eml_generator::ValidateAnnotations; use crate::services::database::{get_hasura_pool, get_keycloak_pool}; +use crate::services::datafix::reconciliation::apply::{apply_voter_changes, VoterApplyOutcome}; +use crate::services::datafix::reconciliation::bulk_create::apply_voters_added_bulk; +use crate::services::datafix::reconciliation::diff::{DiffItem, ReconciliationApplyEnvelope}; +use crate::services::datafix::reconciliation::patch::DiffItemArrayWriter; +use crate::services::datafix::reconciliation::types::{ + ReconciliationChangeCategory, ReconciliationPatchSource, +}; +use crate::services::datafix::utils::set_datafix_reconciliation_state; use crate::services::documents::get_document_as_temp_file; use crate::services::electoral_log::ElectoralLog; -use crate::services::external::reconciliation::apply::{apply_voter_changes, VoterApplyOutcome}; -use crate::services::external::reconciliation::bulk_create::apply_voters_added_bulk; -use crate::services::external::reconciliation::diff::{DiffItem, ReconciliationApplyEnvelope}; -use crate::services::external::reconciliation::patch::DiffItemArrayWriter; -use crate::services::external::types::{ReconciliationChangeCategory, ReconciliationPatchSource}; -use crate::services::external::utils::set_datafix_reconciliation_state; use crate::services::protocol_manager::get_event_board; use crate::services::serialize_tasks_logs::append_general_log; use crate::services::tasks_execution::{update_fail, update_with_annotations}; diff --git a/packages/windmill/src/tasks/edit_user.rs b/packages/windmill/src/tasks/edit_user.rs index 1877e7370da..3592b65b29a 100644 --- a/packages/windmill/src/tasks/edit_user.rs +++ b/packages/windmill/src/tasks/edit_user.rs @@ -7,15 +7,13 @@ use crate::postgres::cast_vote::{ }; use crate::postgres::election_event::{get_election_event_by_id, ElectionEventDatafix}; use crate::services::database::get_hasura_pool; -use crate::services::external; -use crate::services::external::datafix_types::{ - SoapRequest, SoapRequestResponse, SoapRequestResult, -}; -use crate::services::external::utils::{ - external_voter_lock_key, post_operation_result_to_electoral_log, voted_via_internet, +use crate::services::datafix; +use crate::services::datafix::types::{SoapRequest, SoapRequestResponse, SoapRequestResult}; +use crate::services::datafix::utils::{ + datafix_voter_lock_key, post_operation_result_to_electoral_log, voted_via_internet, voted_via_not_internet_channel, DATAFIX_VOTER_LOCK_SECS, }; -use crate::services::external::voterview_requests::SoapSendError; +use crate::services::datafix::voterview_requests::SoapSendError; use crate::services::pg_lock::PgLock; use crate::services::tasks_execution::{update_complete, update_fail}; use crate::types::error::{Error, Result}; @@ -345,7 +343,7 @@ async fn send_set_not_voted( election_event: ElectionEvent, username: &str, ) { - let prepared = match external::voterview_requests::prepare( + let prepared = match datafix::voterview_requests::prepare( SoapRequest::SetNotVoted, ElectionEventDatafix(election_event), &Some(username.to_string()), @@ -365,7 +363,7 @@ async fn send_set_not_voted( } }; let template_sha256 = prepared.template_sha256().to_string(); - let operation = match external::voterview_requests::send_prepared(prepared).await { + let operation = match datafix::voterview_requests::send_prepared(prepared).await { Ok(SoapRequestResult { response, template_sha256, @@ -482,7 +480,7 @@ async fn apply_datafix_voter_edit(body: &EditUserTaskBody) -> std::result::Resul let user_id_uuid = parse_uuid_v4(&body.user_id).map_err(|err| format!("Invalid voter id: {err}"))?; let lock = PgLock::acquire( - external_voter_lock_key(&body.tenant_id, &body.election_event_id, &user_id_uuid), + datafix_voter_lock_key(&body.tenant_id, &body.election_event_id, &user_id_uuid), Uuid::new_v4().to_string(), ISO8601::now() + Duration::seconds(DATAFIX_VOTER_LOCK_SECS), ) diff --git a/packages/windmill/src/tasks/generate_reconciliation_patches.rs b/packages/windmill/src/tasks/generate_reconciliation_patches.rs index 83d34b42c98..67b3f5a3740 100644 --- a/packages/windmill/src/tasks/generate_reconciliation_patches.rs +++ b/packages/windmill/src/tasks/generate_reconciliation_patches.rs @@ -32,20 +32,18 @@ use crate::postgres::document::get_document; use crate::postgres::election_event::{get_election_event_by_id, ElectionEventDatafix}; use crate::services::consolidation::eml_generator::ValidateAnnotations; use crate::services::database::{get_hasura_pool, get_keycloak_pool}; -use crate::services::documents::{get_document_as_temp_file, upload_and_return_document}; -use crate::services::electoral_log::ElectoralLog; -use crate::services::external::reconciliation::csv::{ - split_meta_and_csv, ReconciliationRowBatches, -}; -use crate::services::external::reconciliation::diff::{ +use crate::services::datafix::reconciliation::csv::{split_meta_and_csv, ReconciliationRowBatches}; +use crate::services::datafix::reconciliation::diff::{ diff_file_row_batch, diff_unmatched_sequent_voters, index_datafix_area_fields, DatafixAreaFieldsByName, DiffItem, }; -use crate::services::external::reconciliation::patch::{ +use crate::services::datafix::reconciliation::patch::{ is_sequent_apply_stream_item, sha256_hex, DiffItemArrayWriter, DiffItemNdjsonWriter, ExternalPatchCsvWriter, }; -use crate::services::external::types::ReconciliationPatchSource; +use crate::services::datafix::reconciliation::types::ReconciliationPatchSource; +use crate::services::documents::{get_document_as_temp_file, upload_and_return_document}; +use crate::services::electoral_log::ElectoralLog; use crate::services::protocol_manager::get_event_board; use crate::services::serialize_tasks_logs::append_general_log; use crate::services::tally_sheet_import::hash::hash_bytes; @@ -530,7 +528,7 @@ fn write_batch_to_all_outputs( sequent_patch_writer: &mut DiffItemNdjsonWriter, external_patch_writer: &mut ExternalPatchCsvWriter, batch_items: &[DiffItem], - file_rows: &[crate::services::external::datafix_types::ParsedDatafixReconciliationRow], + file_rows: &[crate::services::datafix::types::ParsedDatafixReconciliationRow], ) -> std::result::Result<(), String> { envelope_items_writer .write_batch(batch_items.iter()) @@ -679,11 +677,11 @@ async fn checkpoint(task_execution: &mut TasksExecution, message: &str) { #[cfg(test)] mod tests { use super::{apply_permission_for_sequence, write_envelope_tail}; - use crate::services::external::reconciliation::diff::{ + use crate::services::datafix::reconciliation::diff::{ DiffItem, ReconciliationApplyEnvelope, ReconciliationDiff, }; - use crate::services::external::reconciliation::patch::DiffItemArrayWriter; - use crate::services::external::types::{ + use crate::services::datafix::reconciliation::patch::DiffItemArrayWriter; + use crate::services::datafix::reconciliation::types::{ ReconciliationChangeCategory, ReconciliationPatchTarget, SequentReconciliationField, }; use std::io::Write; diff --git a/packages/windmill/src/tasks/process_cast_vote.rs b/packages/windmill/src/tasks/process_cast_vote.rs index f90a1691e40..6c96c6a4533 100644 --- a/packages/windmill/src/tasks/process_cast_vote.rs +++ b/packages/windmill/src/tasks/process_cast_vote.rs @@ -8,13 +8,13 @@ use crate::postgres::cast_vote::{ use crate::postgres::election_event::{get_election_event_by_id, ElectionEventDatafix}; use crate::services::cast_votes::{CastVote, CastVoteStatus}; use crate::services::database::get_hasura_pool; -use crate::services::external; -use crate::services::external::datafix_types::{SoapRequest, SoapRequestResponse}; -use crate::services::external::utils::{ - datafix_annotations, external_voter_lock_key, post_operation_result_to_electoral_log, +use crate::services::datafix; +use crate::services::datafix::types::{SoapRequest, SoapRequestResponse}; +use crate::services::datafix::utils::{ + datafix_annotations, datafix_voter_lock_key, post_operation_result_to_electoral_log, voted_via_internet, voted_via_not_internet_channel, DATAFIX_VOTER_LOCK_SECS, }; -use crate::services::external::voterview_requests::SoapSendError; +use crate::services::datafix::voterview_requests::SoapSendError; use crate::services::pg_lock::PgLock; use crate::types::error::Result; use celery::error::TaskError; @@ -63,7 +63,7 @@ pub async fn process_cast_vote( .ok_or("Voter id not found")?; let voter_id = Uuid::parse_str(voter_id).map_err(|err| format!("Invalid voter id: {err}"))?; let lock = match PgLock::acquire( - external_voter_lock_key( + datafix_voter_lock_key( &cast_vote.tenant_id, &cast_vote.election_event_id, &voter_id, @@ -172,7 +172,7 @@ async fn process_locked_cast_vote( return Ok(()); } - let prepared = external::voterview_requests::prepare( + let prepared = datafix::voterview_requests::prepare( SoapRequest::SetVoted, ElectionEventDatafix(election_event), &Some(username.clone()), @@ -185,7 +185,7 @@ async fn process_locked_cast_vote( .map_err(|err| format!("Datafix voter lock was lost before SetVoted: {err}"))?; let template_sha256 = prepared.template_sha256().to_string(); - let result = external::voterview_requests::send_prepared(prepared).await; + let result = datafix::voterview_requests::send_prepared(prepared).await; match &result { Ok(result) => info!( template_sha256 = %result.template_sha256, @@ -485,16 +485,16 @@ mod tests { fn voter_lock_is_event_wide() { let voter = Uuid::new_v4(); let other_voter = Uuid::new_v4(); - let first = external_voter_lock_key("tenant", "event", &voter); - let second = external_voter_lock_key("tenant", "event", &voter); + let first = datafix_voter_lock_key("tenant", "event", &voter); + let second = datafix_voter_lock_key("tenant", "event", &voter); assert_eq!(first, second); assert_ne!( first, - external_voter_lock_key("tenant", "other-event", &voter) + datafix_voter_lock_key("tenant", "other-event", &voter) ); assert_ne!( first, - external_voter_lock_key("tenant", "event", &other_voter) + datafix_voter_lock_key("tenant", "event", &other_voter) ); } } diff --git a/packages/yarn.lock b/packages/yarn.lock index 384ae8e707f..e3b215b1191 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#b2294ce4c15e695c0ea50f080b574fdd6da4a0dc" + resolved "file:./admin-portal/rust/sequent-core-0.1.0.tgz#8225ffa3510fed25c177ea5d5f575b5f5da137f4" "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#b2294ce4c15e695c0ea50f080b574fdd6da4a0dc" + resolved "file:./ballot-verifier/rust/sequent-core-0.1.0.tgz#8225ffa3510fed25c177ea5d5f575b5f5da137f4" "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#b2294ce4c15e695c0ea50f080b574fdd6da4a0dc" + resolved "file:./voting-portal/rust/sequent-core-0.1.0.tgz#8225ffa3510fed25c177ea5d5f575b5f5da137f4" serialize-javascript@^6.0.0, serialize-javascript@^6.0.2: version "6.0.2"