diff --git a/graphql.schema.json b/graphql.schema.json index 2813dbf55cd..32be1f686bc 100644 --- a/graphql.schema.json +++ b/graphql.schema.json @@ -416,6 +416,81 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "OBJECT", + "name": "CastVoteEntry", + "description": null, + "fields": [ + { + "name": "ballot_id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "statement_kind", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "statement_timestamp", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "username", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, { "kind": "OBJECT", "name": "CastVotesByIp", @@ -2296,6 +2371,16 @@ "ofType": null }, "defaultValue": null + }, + { + "name": "username", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null } ], "interfaces": null, @@ -2308,6 +2393,16 @@ "description": null, "fields": null, "inputFields": [ + { + "name": "ballot_id", + "description": null, + "type": { + "kind": "ENUM", + "name": "OrderDirection", + "ofType": null + }, + "defaultValue": null + }, { "name": "created", "description": null, @@ -2357,6 +2452,16 @@ "ofType": null }, "defaultValue": null + }, + { + "name": "username", + "description": null, + "type": { + "kind": "ENUM", + "name": "OrderDirection", + "ofType": null + }, + "defaultValue": null } ], "interfaces": null, @@ -5518,6 +5623,53 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "OBJECT", + "name": "ListCastVoteMessagesOutput", + "description": null, + "fields": [ + { + "name": "list", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CastVoteEntry", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "total", + "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": "ListKeysCeremonyOutput", @@ -26488,6 +26640,101 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "list_cast_vote_messages", + "description": "List electoral log entries of statement_kind CastVote", + "args": [ + { + "name": "ballot_id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "election_event_id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "election_id", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "limit", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "offset", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "order_by", + "description": null, + "type": { + "kind": "INPUT_OBJECT", + "name": "ElectoralLogOrderBy", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "tenant_id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null + } + ], + "type": { + "kind": "OBJECT", + "name": "ListCastVoteMessagesOutput", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "list_keys_ceremony", "description": null, diff --git a/hasura/metadata/actions.graphql b/hasura/metadata/actions.graphql index 2144edfc675..ce773c48a74 100644 --- a/hasura/metadata/actions.graphql +++ b/hasura/metadata/actions.graphql @@ -434,6 +434,18 @@ type Mutation { ): LimitAccessByCountriesOutput } +type Query { + list_cast_vote_messages( + tenant_id: String! + election_event_id: String! + election_id: String + ballot_id: String! + limit: Int + offset: Int + order_by: ElectoralLogOrderBy + ): ListCastVoteMessagesOutput +} + type Query { listElectoralLog( limit: Int @@ -652,6 +664,7 @@ input PgAuditOrderBy { input ElectoralLogFilter { id: String user_id: String + username: String created: String statement_timestamp: String statement_kind: String @@ -663,6 +676,8 @@ input ElectoralLogOrderBy { statement_timestamp: OrderDirection statement_kind: OrderDirection user_id: OrderDirection + username: OrderDirection + ballot_id: OrderDirection } input SampleInput { @@ -1022,6 +1037,18 @@ type ElectoralLogRow { user_id: String! } +type CastVoteEntry { + statement_timestamp: Int! + statement_kind: String! + ballot_id: String! + username: String! +} + +type ListCastVoteMessagesOutput { + list: [CastVoteEntry]! + total: Int! +} + type DataListElectoralLog { items: [ElectoralLogRow]! total: TotalAggregate! diff --git a/hasura/metadata/actions.yaml b/hasura/metadata/actions.yaml index c3452d7c90f..229906ac542 100644 --- a/hasura/metadata/actions.yaml +++ b/hasura/metadata/actions.yaml @@ -948,6 +948,20 @@ actions: permissions: - role: cloudflare-write - role: admin-user + - name: list_cast_vote_messages + definition: + kind: synchronous + handler: http://{{HARVEST_DOMAIN}}/voting-portal/immudb/list-cast-vote-messages + forward_client_headers: true + request_transform: + body: + action: transform + template: "{{$body.input}}" + template_engine: Kriti + version: 2 + permissions: + - role: user + comment: List electoral log entries of statement_kind CastVote - name: listElectoralLog definition: kind: "" @@ -1347,6 +1361,8 @@ custom_types: - name: DataListPgAudit - name: TotalAggregate - name: ElectoralLogRow + - name: CastVoteEntry + - name: ListCastVoteMessagesOutput - name: DataListElectoralLog - name: ScheduledEventOutput2 - name: ScheduledEventOutput3 diff --git a/packages/Cargo.lock b/packages/Cargo.lock index 9fb49395e23..c4107bb0b61 100644 --- a/packages/Cargo.lock +++ b/packages/Cargo.lock @@ -1404,9 +1404,9 @@ dependencies = [ [[package]] name = "borsh" -version = "1.5.1" +version = "1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6362ed55def622cddc70a4746a68554d7b687713770de539e59a739b249f8ed" +checksum = "ad8646f98db542e39fc66e68a20b2144f6a732636df7c2354e74645faaa433ce" dependencies = [ "borsh-derive", "cfg_aliases", @@ -1414,9 +1414,9 @@ dependencies = [ [[package]] name = "borsh-derive" -version = "1.5.3" +version = "1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2593a3b8b938bd68373196c9832f516be11fa487ef4ae745eb282e6a56a7244" +checksum = "fdd1d3c0c2f5833f22386f252fe8ed005c7f59fdcddeef025c01b4c3b9fd9ac3" dependencies = [ "once_cell", "proc-macro-crate", @@ -1671,9 +1671,9 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.39" +version = "0.4.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e36cc9d416881d2e24f9a963be5fb1cd90966419ac844274161d10488b3e825" +checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" dependencies = [ "android-tzdata", "iana-time-zone", @@ -1682,7 +1682,7 @@ dependencies = [ "pure-rust-locales", "serde", "wasm-bindgen", - "windows-targets 0.52.6", + "windows-link", ] [[package]] @@ -2900,6 +2900,7 @@ version = "0.1.0" dependencies = [ "anyhow", "borsh", + "chrono", "clap 4.5.23", "hex", "immudb-rs", @@ -9805,6 +9806,12 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + [[package]] name = "windows-registry" version = "0.2.0" diff --git a/packages/admin-portal/graphql.schema.json b/packages/admin-portal/graphql.schema.json index a292d2b1abb..3c74b892a7f 100644 --- a/packages/admin-portal/graphql.schema.json +++ b/packages/admin-portal/graphql.schema.json @@ -462,6 +462,81 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "OBJECT", + "name": "CastVoteEntry", + "description": null, + "fields": [ + { + "name": "ballot_id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "statement_kind", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "statement_timestamp", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "username", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, { "kind": "OBJECT", "name": "CastVotesByIp", @@ -2486,6 +2561,18 @@ "defaultValue": null, "isDeprecated": false, "deprecationReason": null + }, + { + "name": "username", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null } ], "interfaces": null, @@ -2498,6 +2585,18 @@ "description": null, "fields": null, "inputFields": [ + { + "name": "ballot_id", + "description": null, + "type": { + "kind": "ENUM", + "name": "OrderDirection", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "created", "description": null, @@ -2557,6 +2656,18 @@ "defaultValue": null, "isDeprecated": false, "deprecationReason": null + }, + { + "name": "username", + "description": null, + "type": { + "kind": "ENUM", + "name": "OrderDirection", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null } ], "interfaces": null, @@ -5898,6 +6009,53 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "OBJECT", + "name": "ListCastVoteMessagesOutput", + "description": null, + "fields": [ + { + "name": "list", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CastVoteEntry", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "total", + "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": "ListKeysCeremonyOutput", @@ -29424,6 +29582,115 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "list_cast_vote_messages", + "description": "List electoral log entries of statement_kind CastVote", + "args": [ + { + "name": "ballot_id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "election_event_id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "election_id", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "limit", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "offset", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "order_by", + "description": null, + "type": { + "kind": "INPUT_OBJECT", + "name": "ElectoralLogOrderBy", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "tenant_id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "ListCastVoteMessagesOutput", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "list_keys_ceremony", "description": null, diff --git a/packages/admin-portal/src/components/ElectoralLogList.tsx b/packages/admin-portal/src/components/ElectoralLogList.tsx index 0a8b0745439..6c3567d13d9 100644 --- a/packages/admin-portal/src/components/ElectoralLogList.tsx +++ b/packages/admin-portal/src/components/ElectoralLogList.tsx @@ -270,7 +270,7 @@ export const ElectoralLogList: React.FC = ({ getHeadField(record, "event_type")} /> >; }; +export type CastVoteEntry = { + __typename?: 'CastVoteEntry'; + ballot_id: Scalars['String']['output']; + statement_kind: Scalars['String']['output']; + statement_timestamp: Scalars['Int']['output']; + username: Scalars['String']['output']; +}; + export type CastVotesByIp = { __typename?: 'CastVotesByIp'; country?: Maybe; @@ -296,14 +304,17 @@ export type ElectoralLogFilter = { statement_kind?: InputMaybe; statement_timestamp?: InputMaybe; user_id?: InputMaybe; + username?: InputMaybe; }; export type ElectoralLogOrderBy = { + ballot_id?: InputMaybe; created?: InputMaybe; id?: InputMaybe; statement_kind?: InputMaybe; statement_timestamp?: InputMaybe; user_id?: InputMaybe; + username?: InputMaybe; }; export type ElectoralLogRow = { @@ -678,6 +689,12 @@ export type LimitAccessByCountriesOutput = { success?: Maybe; }; +export type ListCastVoteMessagesOutput = { + __typename?: 'ListCastVoteMessagesOutput'; + list: Array>; + total: Scalars['Int']['output']; +}; + export type ListKeysCeremonyOutput = { __typename?: 'ListKeysCeremonyOutput'; items: Array; @@ -4309,6 +4326,8 @@ export type Query_Root = { listElectoralLog?: Maybe; /** List PostgreSQL audit logs */ listPgaudit?: Maybe; + /** List electoral log entries of statement_kind CastVote */ + list_cast_vote_messages?: Maybe; list_keys_ceremony?: Maybe; list_user_roles: Array; /** log an event in immudb */ @@ -4608,6 +4627,17 @@ export type Query_RootListPgauditArgs = { }; +export type Query_RootList_Cast_Vote_MessagesArgs = { + ballot_id: Scalars['String']['input']; + election_event_id: Scalars['String']['input']; + election_id?: InputMaybe; + limit?: InputMaybe; + offset?: InputMaybe; + order_by?: InputMaybe; + tenant_id: Scalars['String']['input']; +}; + + export type Query_RootList_Keys_CeremonyArgs = { election_event_id: Scalars['String']['input']; }; diff --git a/packages/admin-portal/src/queries/ListElectoralLog.ts b/packages/admin-portal/src/queries/ListElectoralLog.ts index f33b6e162f7..81c1079a6c7 100644 --- a/packages/admin-portal/src/queries/ListElectoralLog.ts +++ b/packages/admin-portal/src/queries/ListElectoralLog.ts @@ -3,7 +3,14 @@ // SPDX-License-Identifier: AGPL-3.0-only import {gql} from "@apollo/client" -const validOrderBy = ["id", "created", "statement_timestamp", "statement_kind", "user_id"] +const validOrderBy = [ + "id", + "created", + "statement_timestamp", + "statement_kind", + "user_id", + "username", +] export const getElectoralLogVariables = (input: any) => { return { diff --git a/packages/admin-portal/src/queries/customBuildQuery.ts b/packages/admin-portal/src/queries/customBuildQuery.ts index 380c1ae0927..2fa065ac90d 100644 --- a/packages/admin-portal/src/queries/customBuildQuery.ts +++ b/packages/admin-portal/src/queries/customBuildQuery.ts @@ -82,11 +82,16 @@ export const customBuildQuery = name: resourceName, }, } + const builtVariables = buildVariables(introspectionResults)( + resource, + raFetchType, + params, + null + ) + const finalVariables = getElectoralLogVariables(builtVariables) return { query: getElectoralLog(params), - variables: getElectoralLogVariables( - buildVariables(introspectionResults)(resource, raFetchType, params, null) - ), + variables: finalVariables, parseResponse: (res: any) => { const response = res.data.listElectoralLog let output = { diff --git a/packages/admin-portal/src/resources/ElectionEvent/EditElectionEventDataForm.tsx b/packages/admin-portal/src/resources/ElectionEvent/EditElectionEventDataForm.tsx index 0653489c828..b23be5663e1 100644 --- a/packages/admin-portal/src/resources/ElectionEvent/EditElectionEventDataForm.tsx +++ b/packages/admin-portal/src/resources/ElectionEvent/EditElectionEventDataForm.tsx @@ -51,6 +51,7 @@ import { EElectionEventOTP, EElectionEventContestEncryptionPolicy, EVoterSigningPolicy, + EShowCastVoteLogsPolicy, } from "@sequentech/ui-core" import {ListActions} from "@/components/ListActions" import {ImportDataDrawer} from "@/components/election-event/import-data/ImportDataDrawer" @@ -458,6 +459,13 @@ export const EditElectionEventDataForm: React.FC = () => { })) } + const showCastVoteLogsChoices = (): Array> => { + return Object.values(EShowCastVoteLogsPolicy).map((value) => ({ + id: value, + name: t(`electionEventScreen.field.showCastVoteLogs.options.${value.toLowerCase()}`), + })) + } + const handleImportCandidates = async (documentId: string, sha256: string) => { setOpenImportCandidates(false) const currWidget = addWidget(ETasksExecution.IMPORT_CANDIDATES) @@ -800,6 +808,15 @@ export const EditElectionEventDataForm: React.FC = () => { choices={orderAnswerChoices()} validate={required()} /> + {({formData, ...rest}) => { return ( diff --git a/packages/admin-portal/src/translations/cat.ts b/packages/admin-portal/src/translations/cat.ts index 88088aeb8ed..db630bcf82b 100644 --- a/packages/admin-portal/src/translations/cat.ts +++ b/packages/admin-portal/src/translations/cat.ts @@ -279,6 +279,13 @@ const catalanTranslation: TranslationType = { css: "CSS personalitzat", skipElectionList: "Saltar pantalla per escollir elecció", showUserProfile: "Mostra el perfil de l'usuari", + showCastVoteLogs: { + policyLabel: "Mostra els registres de votació", + options: { + "show-logs-tab": "Mostra els registres de votació", + "hide-logs-tab": "Amaga els registres de votació", + }, + }, lockdownState: { policyLabel: "Estat de Confinament", options: { diff --git a/packages/admin-portal/src/translations/en.ts b/packages/admin-portal/src/translations/en.ts index a2c83bad4e7..a94feb25ec1 100644 --- a/packages/admin-portal/src/translations/en.ts +++ b/packages/admin-portal/src/translations/en.ts @@ -278,6 +278,13 @@ const englishTranslation = { css: "Custom CSS", skipElectionList: "Skip Election List Screen", showUserProfile: "Show User Profile", + showCastVoteLogs: { + policyLabel: "Show Cast Vote Logs Tab", + options: { + "show-logs-tab": "Show Cast Vote Logs Tab", + "hide-logs-tab": "Hide Cast Vote Logs Tab", + }, + }, lockdownState: { policyLabel: "Lockdown Status", options: { diff --git a/packages/admin-portal/src/translations/es.ts b/packages/admin-portal/src/translations/es.ts index 2e8049f7357..22b37a46c85 100644 --- a/packages/admin-portal/src/translations/es.ts +++ b/packages/admin-portal/src/translations/es.ts @@ -279,6 +279,13 @@ const spanishTranslation: TranslationType = { css: "CSS personalizado", skipElectionList: "Saltar pantalla para escoger elección", showUserProfile: "Mostrar perfil de usuario", + showCastVoteLogs: { + policyLabel: "Mostrar logs de votación", + options: { + "show-logs-tab": "", + "hide-logs-tab": "", + }, + }, lockdownState: { policyLabel: "Estado de Confinamiento", options: { diff --git a/packages/admin-portal/src/translations/eu.ts b/packages/admin-portal/src/translations/eu.ts index abd93ffa03d..c70afd3bc77 100644 --- a/packages/admin-portal/src/translations/eu.ts +++ b/packages/admin-portal/src/translations/eu.ts @@ -280,6 +280,13 @@ const basqueTranslation: TranslationType = { css: "CSS Pertsonalizatua", skipElectionList: "Saltatu Hauteskunde Zerrenda Pantaila", showUserProfile: "Erakutsi Erabiltzaile Profila", + showCastVoteLogs: { + policyLabel: "Erakutsi Logs Bozketa Taba", + options: { + "show-logs-tab": "Erakutsi Logs Bozketa Taba", + "hide-logs-tab": "Ez Erakutsi Logs Bozketa Taba", + }, + }, lockdownState: { policyLabel: "Blokeo Egoera", options: { diff --git a/packages/admin-portal/src/translations/fr.ts b/packages/admin-portal/src/translations/fr.ts index 45b7faab0fb..9727d3a467e 100644 --- a/packages/admin-portal/src/translations/fr.ts +++ b/packages/admin-portal/src/translations/fr.ts @@ -279,6 +279,13 @@ const frenchTranslation: TranslationType = { css: "CSS personnalisé", skipElectionList: "Passer l'écran pour choisir l'élection", showUserProfile: "Afficher le profil utilisateur", + showCastVoteLogs: { + policyLabel: "Afficher les logs de vote", + options: { + "show-logs-tab": "Afficher l'onglet des logs de vote", + "hide-logs-tab": "Ne pas afficher l'onglet des logs de vote", + }, + }, lockdownState: { policyLabel: "État de Confinement", options: { diff --git a/packages/admin-portal/src/translations/gl.ts b/packages/admin-portal/src/translations/gl.ts index 963c6da2829..aa52c39fcde 100644 --- a/packages/admin-portal/src/translations/gl.ts +++ b/packages/admin-portal/src/translations/gl.ts @@ -280,6 +280,13 @@ const galegoTranslation: TranslationType = { css: "CSS Personalizado", skipElectionList: "Omitir Pantalla de Lista de Eleccións", showUserProfile: "Mostrar Perfil do Usuario", + showCastVoteLogs: { + policyLabel: "Mostrar Tab de Logs de Votación", + options: { + "show-logs-tab": "Mostrar Tab de Logs de Votación", + "hide-logs-tab": "No Mostrar Tab de Logs de Votación", + }, + }, lockdownState: { policyLabel: "Estado de Bloqueo", options: { diff --git a/packages/admin-portal/src/translations/nl.ts b/packages/admin-portal/src/translations/nl.ts index c7ccb1236f6..6a1d878fa81 100644 --- a/packages/admin-portal/src/translations/nl.ts +++ b/packages/admin-portal/src/translations/nl.ts @@ -279,6 +279,13 @@ const dutchTranslation: TranslationType = { css: "Aangepaste CSS", skipElectionList: "Scherm verkiezingslijst overslaan", showUserProfile: "Gebruikersprofiel tonen", + showCastVoteLogs: { + policyLabel: "Logboeken stemmen tonen", + options: { + "show-logs-tab": "Tab log stemmen tonen", + "hide-logs-tab": "Tab log stemmen verbergen", + }, + }, lockdownState: { policyLabel: "Vergrendelingsstatus", options: { diff --git a/packages/admin-portal/src/translations/tl.ts b/packages/admin-portal/src/translations/tl.ts index 39c97631bcc..25404507205 100644 --- a/packages/admin-portal/src/translations/tl.ts +++ b/packages/admin-portal/src/translations/tl.ts @@ -280,6 +280,13 @@ const tagalogTranslation: TranslationType = { css: "Custom CSS", skipElectionList: "Laktawan ang Screen ng Listahan ng Halalan", showUserProfile: "Ipakita ang Profile ng Gumagamit", + showCastVoteLogs: { + policyLabel: "Patakaran sa Ipakita ng mga Log ng Pagboto", + options: { + "show-logs-tab": "Ipakita ang Tab ng mga Log ng Pagboto", + "hide-logs-tab": "Laktawan ang Tab ng mga Log ng Pagboto", + }, + }, lockdownState: { policyLabel: "Kalagayan ng Lockdown", options: { diff --git a/packages/b3/Cargo.toml b/packages/b3/Cargo.toml index 14519365aa1..01080d76569 100644 --- a/packages/b3/Cargo.toml +++ b/packages/b3/Cargo.toml @@ -14,7 +14,7 @@ license = "AGPL-3.0-only" [dependencies] strand = { path="../strand", features=["rayon"] } -borsh = { version = "=1.5.1", features = ["derive"] } +borsh = { version = "1.5", features = ["derive"] } strum = { version = "0.26.3", features = ["derive"] } anyhow = "1.0" rayon = "1.5" diff --git a/packages/ballot-verifier/graphql.schema.json b/packages/ballot-verifier/graphql.schema.json index a292d2b1abb..3c74b892a7f 100644 --- a/packages/ballot-verifier/graphql.schema.json +++ b/packages/ballot-verifier/graphql.schema.json @@ -462,6 +462,81 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "OBJECT", + "name": "CastVoteEntry", + "description": null, + "fields": [ + { + "name": "ballot_id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "statement_kind", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "statement_timestamp", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "username", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, { "kind": "OBJECT", "name": "CastVotesByIp", @@ -2486,6 +2561,18 @@ "defaultValue": null, "isDeprecated": false, "deprecationReason": null + }, + { + "name": "username", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null } ], "interfaces": null, @@ -2498,6 +2585,18 @@ "description": null, "fields": null, "inputFields": [ + { + "name": "ballot_id", + "description": null, + "type": { + "kind": "ENUM", + "name": "OrderDirection", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "created", "description": null, @@ -2557,6 +2656,18 @@ "defaultValue": null, "isDeprecated": false, "deprecationReason": null + }, + { + "name": "username", + "description": null, + "type": { + "kind": "ENUM", + "name": "OrderDirection", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null } ], "interfaces": null, @@ -5898,6 +6009,53 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "OBJECT", + "name": "ListCastVoteMessagesOutput", + "description": null, + "fields": [ + { + "name": "list", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CastVoteEntry", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "total", + "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": "ListKeysCeremonyOutput", @@ -29424,6 +29582,115 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "list_cast_vote_messages", + "description": "List electoral log entries of statement_kind CastVote", + "args": [ + { + "name": "ballot_id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "election_event_id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "election_id", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "limit", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "offset", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "order_by", + "description": null, + "type": { + "kind": "INPUT_OBJECT", + "name": "ElectoralLogOrderBy", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "tenant_id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "ListCastVoteMessagesOutput", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "list_keys_ceremony", "description": null, diff --git a/packages/ballot-verifier/src/gql/graphql.ts b/packages/ballot-verifier/src/gql/graphql.ts index a2b4c3a8b4b..d17fc300f8e 100644 --- a/packages/ballot-verifier/src/gql/graphql.ts +++ b/packages/ballot-verifier/src/gql/graphql.ts @@ -76,6 +76,14 @@ export type Boolean_Comparison_Exp = { _nin?: InputMaybe> } +export type CastVoteEntry = { + __typename?: "CastVoteEntry" + ballot_id: Scalars["String"]["output"] + statement_kind: Scalars["String"]["output"] + statement_timestamp: Scalars["Int"]["output"] + username: Scalars["String"]["output"] +} + export type CastVotesByIp = { __typename?: "CastVotesByIp" country?: Maybe @@ -298,14 +306,17 @@ export type ElectoralLogFilter = { statement_kind?: InputMaybe statement_timestamp?: InputMaybe user_id?: InputMaybe + username?: InputMaybe } export type ElectoralLogOrderBy = { + ballot_id?: InputMaybe created?: InputMaybe id?: InputMaybe statement_kind?: InputMaybe statement_timestamp?: InputMaybe user_id?: InputMaybe + username?: InputMaybe } export type ElectoralLogRow = { @@ -680,6 +691,12 @@ export type LimitAccessByCountriesOutput = { success?: Maybe } +export type ListCastVoteMessagesOutput = { + __typename?: "ListCastVoteMessagesOutput" + list: Array> + total: Scalars["Int"]["output"] +} + export type ListKeysCeremonyOutput = { __typename?: "ListKeysCeremonyOutput" items: Array @@ -4059,6 +4076,8 @@ export type Query_Root = { listElectoralLog?: Maybe /** List PostgreSQL audit logs */ listPgaudit?: Maybe + /** List electoral log entries of statement_kind CastVote */ + list_cast_vote_messages?: Maybe list_keys_ceremony?: Maybe list_user_roles: Array /** log an event in immudb */ @@ -4344,6 +4363,16 @@ export type Query_RootListPgauditArgs = { order_by?: InputMaybe } +export type Query_RootList_Cast_Vote_MessagesArgs = { + ballot_id: Scalars["String"]["input"] + election_event_id: Scalars["String"]["input"] + election_id?: InputMaybe + limit?: InputMaybe + offset?: InputMaybe + order_by?: InputMaybe + tenant_id: Scalars["String"]["input"] +} + export type Query_RootList_Keys_CeremonyArgs = { election_event_id: Scalars["String"]["input"] } diff --git a/packages/electoral-log/Cargo.toml b/packages/electoral-log/Cargo.toml index 07244749335..4d73f30fb97 100644 --- a/packages/electoral-log/Cargo.toml +++ b/packages/electoral-log/Cargo.toml @@ -10,7 +10,7 @@ edition = "2021" [dependencies] clap = { version = "4.0", features = ["derive"] } immudb-rs = { path="../immudb-rs" } -borsh = { version = "=1.5.1", features = ["derive"] } +borsh = { version = "1.5", features = ["derive"] } anyhow = "1.0.75" strand = { path="../strand" } serde = { version = "1.0.190", features = ["derive"] } @@ -29,6 +29,7 @@ tracing-log = { version = "0.1.3" } tracing-attributes = "0.1.23" tracing-subscriber = "0.3.16" tracing-tree = "0.2.1" +chrono = "0.4.41" [dev-dependencies] serial_test = "2.0.0" diff --git a/packages/electoral-log/src/client/board_client.rs b/packages/electoral-log/src/client/board_client.rs index a0a874fba62..4a9dba417a0 100644 --- a/packages/electoral-log/src/client/board_client.rs +++ b/packages/electoral-log/src/client/board_client.rs @@ -2,138 +2,47 @@ // // SPDX-License-Identifier: AGPL-3.0-only -use crate::assign_value; +use crate::client::types::*; use anyhow::{anyhow, Context, Result}; +use chrono::format; use immudb_rs::{sql_value::Value, Client, CommittedSqlTx, NamedParam, Row, SqlValue, TxMode}; -use serde::{Deserialize, Serialize}; + +use std::collections::HashMap; use std::fmt::Debug; -use tokio::time::{sleep, Duration}; +use std::fmt::Display; +use std::time::Duration; +use std::time::Instant; use tokio_stream::StreamExt; // Added for streaming -use tracing::{debug, error, info, warn}; -use tracing::{event, instrument, Level}; +use tracing::{error, info, instrument, warn}; const IMMUDB_DEFAULT_LIMIT: usize = 900; const IMMUDB_DEFAULT_ENTRIES_TX_LIMIT: usize = 50; const IMMUDB_DEFAULT_OFFSET: usize = 0; const ELECTORAL_LOG_TABLE: &'static str = "electoral_log_messages"; +/// 36 chars + EOL + some padding +const ID_VARCHAR_LENGTH: usize = 40; +const ID_KEY_VARCHAR_LENGTH: usize = 4; +/// Longest possible statement kind must be < 40 +const STATEMENT_KIND_VARCHAR_LENGTH: usize = 40; +/// 64 chars + EOL + some padding +const BALLOT_ID_VARCHAR_LENGTH: usize = 70; + +/// This is the order of the cols in the where clauses, as defined in ElectoralLogVarCharColumn: +/// StatementKind, AreaId, ElectionId, UserId, BallotId, statement_timestamp. +/// +/// Other columns that have no length constraint are not indexable. +/// 'create' is not indexed, we use statement_timestamp intead. +pub const MULTI_COLUMN_INDEXES: [&'static str; 3] = [ + "(statement_kind, election_id, user_id_key, user_id, ballot_id)", // COUNT or SELECT cast_vote_messages and filter by ballot_id + "(statement_kind, user_id_key, user_id)", // Filters in Admin portal LOGS tab. + "(user_id_key, user_id)", // Filters in Admin portal LOGS tab and for the User´s logs. +]; #[derive(Debug)] pub struct BoardClient { client: Client, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct ElectoralLogMessage { - pub id: i64, - pub created: i64, - pub sender_pk: String, - pub statement_timestamp: i64, - pub statement_kind: String, - pub message: Vec, - pub version: String, - pub user_id: Option, - pub username: Option, - pub election_id: Option, - pub area_id: Option, -} - -impl TryFrom<&Row> for ElectoralLogMessage { - type Error = anyhow::Error; - - fn try_from(row: &Row) -> Result { - let mut id = 0; - let mut created = 0; - let mut sender_pk = String::from(""); - let mut statement_timestamp = 0; - let mut statement_kind = String::from(""); - let mut message = vec![]; - let mut version = String::from(""); - let mut user_id: Option = None; - let mut username: Option = None; - let mut election_id: Option = None; - let mut area_id: Option = None; - - for (column, value) in row.columns.iter().zip(row.values.iter()) { - // FIXME for some reason columns names appear with parentheses - let dot = column - .find('.') - .ok_or(anyhow!("invalid column found '{}'", column.as_str()))?; - let bare_column = &column[dot + 1..column.len() - 1]; - - match bare_column { - "id" => assign_value!(Value::N, value, id), - "created" => assign_value!(Value::Ts, value, created), - "sender_pk" => assign_value!(Value::S, value, sender_pk), - "statement_timestamp" => { - assign_value!(Value::Ts, value, statement_timestamp) - } - "statement_kind" => assign_value!(Value::S, value, statement_kind), - "message" => assign_value!(Value::Bs, value, message), - "version" => assign_value!(Value::S, value, version), - "user_id" => match value.value.as_ref() { - Some(Value::S(inner)) => user_id = Some(inner.clone()), - Some(Value::Null(_)) => user_id = None, - None => user_id = None, - _ => { - return Err(anyhow!( - "invalid column value for 'user_id': {:?}", - value.value.as_ref() - )) - } - }, - "username" => match value.value.as_ref() { - Some(Value::S(inner)) => username = Some(inner.clone()), - Some(Value::Null(_)) => username = None, - None => username = None, - _ => { - return Err(anyhow!( - "invalid column value for 'username': {:?}", - value.value.as_ref() - )) - } - }, - "election_id" => match value.value.as_ref() { - Some(Value::S(inner)) => election_id = Some(inner.clone()), - Some(Value::Null(_)) => election_id = None, - None => election_id = None, - _ => { - return Err(anyhow!( - "invalid column value for 'election_id': {:?}", - value.value.as_ref() - )) - } - }, - "area_id" => match value.value.as_ref() { - Some(Value::S(inner)) => area_id = Some(inner.clone()), - Some(Value::Null(_)) => area_id = None, - None => area_id = None, - _ => { - return Err(anyhow!( - "invalid column value for 'area_id': {:?}", - value.value.as_ref() - )) - } - }, - _ => return Err(anyhow!("invalid column found '{}'", bare_column)), - } - } - - Ok(ElectoralLogMessage { - id, - created, - sender_pk, - statement_timestamp, - statement_kind, - message, - version, - user_id, - username, - election_id, - area_id, - }) - } -} - impl BoardClient { #[instrument(skip(password), level = "trace")] pub async fn new(server_url: &str, username: &str, password: &str) -> Result { @@ -192,6 +101,8 @@ impl BoardClient { message, version, user_id, + area_id, + ballot_id, username FROM {} WHERE id > @last_id @@ -222,71 +133,97 @@ impl BoardClient { Ok(messages) } - pub async fn get_electoral_log_messages_filtered( + /// columns_matcher represents the columns that will be used to filter the messages, + /// The order as defined ElectoralLogVarCharColumn is important for preformance to match the indexes. + /// BTreeMap ensures the order is preserved no matter the insertion sequence. + #[instrument(skip_all, err)] + pub async fn get_electoral_log_messages_filtered( &mut self, board_db: &str, - kind: &str, - sender_pk: Option<&str>, + columns_matcher: Option, min_ts: Option, max_ts: Option, + limit: Option, + offset: Option, + order_by: Option>, + ) -> Result> + where + K: Debug + Display, + V: Debug + Display, + { + self.get_filtered( + board_db, + columns_matcher, + min_ts, + max_ts, + limit, + offset, + order_by, + ) + .await + } + + #[instrument(skip_all, err)] + pub async fn get_electoral_log_messages_batch( + &mut self, + board_db: &str, + limit: i64, + offset: i64, ) -> Result> { - self.get_filtered(board_db, kind, sender_pk, min_ts, max_ts) - .await + self.get_filtered::( + board_db, + None, + None, + None, + Some(limit), + Some(offset), + None, + ) + .await } - async fn get_filtered( + #[instrument(skip(self, board_db, order_by), err)] + async fn get_filtered( &mut self, board_db: &str, - kind: &str, - sender_pk: Option<&str>, + columns_matcher: Option, min_ts: Option, max_ts: Option, - ) -> Result> { + limit: Option, + offset: Option, + order_by: Option>, + ) -> Result> + where + K: Debug + Display, + V: Debug + Display, + { + let start = Instant::now(); let (min_clause, min_clause_value) = if let Some(min_ts) = min_ts { - ("AND created >= @min_ts", min_ts) + ("AND statement_timestamp >= @min_ts", min_ts) } else { ("", 0) }; let (max_clause, max_clause_value) = if let Some(max_ts) = max_ts { - ("AND created <= @max_ts", max_ts) + ("AND statement_timestamp <= @max_ts", max_ts) } else { ("", 0) }; - let (sender_pk_clause, sender_pk_value) = if let Some(sender_pk) = sender_pk { - ("AND sender_pk = @sender_pk", sender_pk) + let (where_clause, mut params) = columns_matcher + .clone() + .unwrap_or_default() + .to_where_clause(); + let order_by_clauses = if let Some(order_by) = order_by { + order_by + .iter() + .map(|(field, direction)| format!("ORDER BY {field} {direction}")) + .collect::>() + .join(", ") } else { - ("", "") + format!("ORDER BY id desc") }; - self.client.use_database(board_db).await?; - let sql = format!( - r#" - SELECT - id, - created, - sender_pk, - statement_timestamp, - statement_kind, - message, - version - FROM {} - WHERE statement_kind = @statement_kind - {} - {} - {} - ORDER BY id; - "#, - ELECTORAL_LOG_TABLE, min_clause, max_clause, sender_pk_clause - ); - - let mut params = vec![NamedParam { - name: String::from("statement_kind"), - value: Some(SqlValue { - value: Some(Value::S(kind.to_string())), - }), - }]; if min_clause_value != 0 { params.push(NamedParam { name: String::from("min_ts"), @@ -303,15 +240,62 @@ impl BoardClient { }), }) } - if !sender_pk_value.is_empty() { - params.push(NamedParam { - name: String::from("sender_pk"), - value: Some(SqlValue { - value: Some(Value::S(sender_pk_value.to_string())), - }), - }) - } + params.push(NamedParam { + name: String::from("limit"), + value: Some(SqlValue { + value: Some(Value::N(limit.unwrap_or(IMMUDB_DEFAULT_LIMIT as i64))), + }), + }); + + params.push(NamedParam { + name: String::from("offset"), + value: Some(SqlValue { + value: Some(Value::N(offset.unwrap_or(IMMUDB_DEFAULT_OFFSET as i64))), + }), + }); + + let where_clauses = + if !where_clause.is_empty() || !min_clause.is_empty() || !max_clause.is_empty() { + format!( + r#" + WHERE {where_clause} + {min_clause} + {max_clause} + "# + ) + } else { + String::from("") + }; + + let use_index_clause = columns_matcher.unwrap_or_default().to_use_index_clause(); + + self.client.use_database(board_db).await?; + let sql = format!( + r#" + SELECT + id, + username, + user_id, + area_id, + election_id, + ballot_id, + created, + sender_pk, + statement_timestamp, + statement_kind, + message, + version + FROM {ELECTORAL_LOG_TABLE} + {use_index_clause} + {where_clauses} + {order_by_clauses} + LIMIT @limit + OFFSET @offset; + "# + ); + + info!("SQL query: {}", sql); let response_stream = self.client.streaming_sql_query(&sql, params) .await .with_context(|| "Failed to execute streaming_sql_query using immudb-rs v0.1.0. This version streams batches (SqlQueryResult).")?; @@ -348,10 +332,102 @@ impl BoardClient { } } } - + let duration = start.elapsed(); + info!( + "Processed {} rows from stream in {}ms", + total_rows_fetched, + duration.as_millis() + ); Ok(messages) } + #[instrument(skip(self, board_db), err)] + pub async fn count_electoral_log_messages( + &mut self, + board_db: &str, + columns_matcher: Option, + ) -> Result { + let start = Instant::now(); + let (where_clause, params) = columns_matcher + .clone() + .unwrap_or_default() + .to_where_clause(); + let where_clauses = if !where_clause.is_empty() { + format!( + r#" + WHERE {where_clause} + "# + ) + } else { + String::from("") + }; + let use_index_clause = columns_matcher.unwrap_or_default().to_use_index_clause(); + self.client.use_database(board_db).await?; + + let count = if use_index_clause.is_empty() && where_clauses.is_empty() && params.is_empty() + { + // if there are no constraints, just get the last id as the count to avoid a full scan of the table. + let sql = format!( + r#" + SELECT + id, + username, + user_id, + area_id, + election_id, + ballot_id, + created, + sender_pk, + statement_timestamp, + statement_kind, + message, + version + FROM {ELECTORAL_LOG_TABLE} + ORDER BY id desc + LIMIT 1 + OFFSET 0; + "# + ); + info!("SQL query: {}", sql); + let sql_query_response = self.client.sql_query(&sql, vec![]).await?; + let elog_msg = sql_query_response + .get_ref() + .rows + .iter() + .map(ElectoralLogMessage::try_from) + .next(); + match elog_msg { + Some(elog_msg) => elog_msg?.id, + None => 0, + } + } else { + let sql = format!( + r#" + SELECT COUNT(*) + FROM {ELECTORAL_LOG_TABLE} + {use_index_clause} + {where_clauses} + "#, + ); + + info!("SQL query: {}", sql); + let sql_query_response = self.client.sql_query(&sql, params).await?; + let mut rows_iter = sql_query_response + .get_ref() + .rows + .iter() + .map(Aggregate::try_from); + let aggregate = rows_iter + .next() + .ok_or_else(|| anyhow!("No aggregate found"))??; + aggregate.count + }; + + let duration = start.elapsed(); + info!("COUNT query took {}ms", duration.as_millis()); + Ok(count as i64) + } + pub async fn open_session(&mut self, database_name: &str) -> Result<()> { self.client.open_session(database_name).await } @@ -368,7 +444,8 @@ impl BoardClient { self.client.commit(transaction_id).await } - // Insert messages in batch using an existing session/transaction + /// Insert messages in batch using an existing session/transaction + #[instrument(skip(self, messages), err)] pub async fn insert_electoral_log_messages_batch( &mut self, transaction_id: &String, @@ -386,10 +463,12 @@ impl BoardClient { statement_timestamp, message, version, + user_id_key, user_id, username, election_id, - area_id + area_id, + ballot_id ) VALUES ( @created, @sender_pk, @@ -397,10 +476,12 @@ impl BoardClient { @statement_timestamp, @message, @version, + @user_id_key, @user_id, @username, @election_id, - @area_id + @area_id, + @ballot_id ); "#, ELECTORAL_LOG_TABLE @@ -442,6 +523,15 @@ impl BoardClient { value: Some(Value::S(message.version.clone())), }), }, + NamedParam { + name: String::from("user_id_key"), + value: Some(SqlValue { + value: match message.user_id.clone() { + Some(user_id) => Some(Value::S(user_id.chars().take(3).collect())), + None => None, + }, + }), + }, NamedParam { name: String::from("user_id"), value: Some(SqlValue { @@ -478,6 +568,15 @@ impl BoardClient { }, }), }, + NamedParam { + name: String::from("ballot_id"), + value: Some(SqlValue { + value: match message.ballot_id.clone() { + Some(ballot_id) => Some(Value::S(ballot_id)), + None => None, + }, + }), + }, ]; let result = self .client @@ -513,10 +612,12 @@ impl BoardClient { statement_timestamp, message, version, + user_id_key, user_id, username, election_id, - area_id + area_id, + ballot_id ) VALUES ( @created, @sender_pk, @@ -524,10 +625,12 @@ impl BoardClient { @statement_timestamp, @message, @version, + @user_id_key, @user_id, @username, @election_id, - @area_id + @area_id, + @ballot_id ); "#, ELECTORAL_LOG_TABLE @@ -569,6 +672,15 @@ impl BoardClient { value: Some(Value::S(message.version.clone())), }), }, + NamedParam { + name: String::from("user_id_key"), + value: Some(SqlValue { + value: match message.user_id.clone() { + Some(user_id) => Some(Value::S(user_id.chars().take(3).collect())), + None => None, + }, + }), + }, NamedParam { name: String::from("user_id"), value: Some(SqlValue { @@ -605,6 +717,15 @@ impl BoardClient { }, }), }, + NamedParam { + name: String::from("ballot_id"), + value: Some(SqlValue { + value: match message.ballot_id.clone() { + Some(ballot_id) => Some(Value::S(ballot_id)), + None => None, + }, + }), + }, ]; let result = self .client @@ -640,24 +761,34 @@ impl BoardClient { pub async fn upsert_electoral_log_db(&mut self, board_dbname: &str) -> Result<()> { let sql = format!( r#" - CREATE TABLE IF NOT EXISTS {} ( + CREATE TABLE IF NOT EXISTS {ELECTORAL_LOG_TABLE} ( id INTEGER AUTO_INCREMENT, created TIMESTAMP, sender_pk VARCHAR, statement_timestamp TIMESTAMP, - statement_kind VARCHAR, + statement_kind VARCHAR[{STATEMENT_KIND_VARCHAR_LENGTH}], message BLOB, version VARCHAR, - user_id VARCHAR, + user_id_key VARCHAR[{ID_KEY_VARCHAR_LENGTH}], + user_id VARCHAR[{ID_VARCHAR_LENGTH}], username VARCHAR, - election_id VARCHAR, - area_id VARCHAR, + election_id VARCHAR[{ID_VARCHAR_LENGTH}], + area_id VARCHAR[{ID_VARCHAR_LENGTH}], + ballot_id VARCHAR[{BALLOT_ID_VARCHAR_LENGTH}], PRIMARY KEY id ); - "#, - ELECTORAL_LOG_TABLE + "# ); - self.upsert_database(board_dbname, &sql).await + + let mut elog_indexes = vec![]; + for mult_col_idx in MULTI_COLUMN_INDEXES { + elog_indexes.push(format!( + "CREATE INDEX IF NOT EXISTS ON {ELECTORAL_LOG_TABLE} {mult_col_idx}" + )); + } + + self.upsert_database(board_dbname, &sql, elog_indexes.as_slice()) + .await } /// Deletes the immudb database. @@ -670,21 +801,30 @@ impl BoardClient { } /// Creates the requested immudb database, only if it doesn't exist. It also creates - /// the requested tables if they don't exist. - async fn upsert_database(&mut self, database_name: &str, tables: &str) -> Result<()> { + /// the requested tables and indexes if they don't exist. + async fn upsert_database( + &mut self, + database_name: &str, + tables: &str, + indexes: &[String], + ) -> Result<()> { // create database if it doesn't exist if !self.client.has_database(database_name).await? { println!("Database not found, creating.."); self.client.create_database(database_name).await?; - event!(Level::INFO, "Database created!"); + info!("Database created!"); }; self.client.use_database(database_name).await?; // List tables and create them if missing if !self.client.has_tables().await? { - event!(Level::INFO, "no tables! let's create them"); + info!("no tables! let's create them"); self.client.sql_exec(&tables, vec![]).await?; } + for index in indexes { + info!("Inserting index..."); + self.client.sql_exec(index, vec![]).await?; + } Ok(()) } } @@ -731,6 +871,7 @@ pub(crate) mod tests { username: None, election_id: None, area_id: None, + ballot_id: None, }; let messages = vec![electoral_log_message]; @@ -740,28 +881,79 @@ pub(crate) mod tests { let ret = b.get_electoral_log_messages(BOARD_DB).await.unwrap(); assert_eq!(messages, ret); + + let cols_match = WhereClauseOrdMap::from(&[ + ( + ElectoralLogVarCharColumn::StatementKind, + (SqlCompOperators::Equal, "".to_string()), + ), + ( + ElectoralLogVarCharColumn::SenderPk, + (SqlCompOperators::Equal, "".to_string()), + ), + ]); let ret = b - .get_electoral_log_messages_filtered(BOARD_DB, "", Some(""), None, None) + .get_electoral_log_messages_filtered( + BOARD_DB, + Some(cols_match), + None, + None, + None, + None, + None, + ) .await .unwrap(); assert_eq!(messages, ret); let ret = b - .get_electoral_log_messages_filtered(BOARD_DB, "", Some(""), Some(1i64), None) + .get_electoral_log_messages_filtered( + BOARD_DB, + Some(cols_match), + Some(1i64), + None, + None, + None, + None, + ) .await .unwrap(); assert_eq!(messages, ret); let ret = b - .get_electoral_log_messages_filtered(BOARD_DB, "", Some(""), None, Some(556i64)) + .get_electoral_log_messages_filtered( + BOARD_DB, + Some(cols_match), + None, + Some(556i64), + None, + None, + None, + ) .await .unwrap(); assert_eq!(messages, ret); let ret = b - .get_electoral_log_messages_filtered(BOARD_DB, "", Some(""), Some(1i64), Some(556i64)) + .get_electoral_log_messages_filtered( + BOARD_DB, + Some(cols_match), + Some(1i64), + Some(556i64), + None, + None, + None, + ) .await .unwrap(); assert_eq!(messages, ret); let ret = b - .get_electoral_log_messages_filtered(BOARD_DB, "", Some(""), Some(556i64), Some(666i64)) + .get_electoral_log_messages_filtered( + BOARD_DB, + Some(cols_match), + Some(556i64), + Some(666i64), + None, + None, + None, + ) .await .unwrap(); assert_eq!(ret.len(), 0); diff --git a/packages/electoral-log/src/client/mod.rs b/packages/electoral-log/src/client/mod.rs index f3321bcbc32..51d25d95f93 100644 --- a/packages/electoral-log/src/client/mod.rs +++ b/packages/electoral-log/src/client/mod.rs @@ -2,3 +2,4 @@ // // SPDX-License-Identifier: AGPL-3.0-only pub mod board_client; +pub mod types; diff --git a/packages/electoral-log/src/client/types.rs b/packages/electoral-log/src/client/types.rs new file mode 100644 index 00000000000..8ff7c9c0c27 --- /dev/null +++ b/packages/electoral-log/src/client/types.rs @@ -0,0 +1,481 @@ +// SPDX-FileCopyrightText: 2024 Sequent Tech +// +// SPDX-License-Identifier: AGPL-3.0-only + +use crate::assign_value; +use crate::client::board_client::MULTI_COLUMN_INDEXES; +use crate::messages::statement::{StatementBody, StatementType}; +use anyhow::{anyhow, Result}; +use chrono::{DateTime, Utc}; +use immudb_rs::{sql_value::Value, NamedParam, Row, SqlValue}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::collections::HashMap; +use std::fmt::Debug; +use std::str::FromStr; +use strum_macros::{Display, EnumString}; +use tracing::{error, info, instrument, warn}; + +#[derive(Debug, Clone, Display, PartialEq, Eq, Ord, PartialOrd, EnumString)] +#[strum(serialize_all = "snake_case")] +pub enum ElectoralLogVarCharColumn { + StatementKind, + AreaId, + ElectionId, + UserIdKey, + UserId, + BallotId, + Username, + SenderPk, + Version, +} + +/// SQL comparison operators supported by immudb. +/// ILIKE is not supported. +#[derive(Display, Debug, Clone)] +pub enum SqlCompOperators { + #[strum(to_string = "=")] + Equal(String), + #[strum(to_string = "!=")] + NotEqual(String), + #[strum(to_string = ">")] + GreaterThan(String), + #[strum(to_string = "<")] + LessThan(String), + #[strum(to_string = ">=")] + GreaterThanOrEqual(String), + #[strum(to_string = "<=")] + LessThanOrEqual(String), + #[strum(to_string = "LIKE")] + Like(String), + #[strum(to_string = "IN")] + In(Vec), + #[strum(to_string = "NOT IN")] + NotIn(Vec), +} + +/// Each column in the map is unique but it can have several filters associated with it. +/// The type will keep the order of the columns to match the multicolumn indexes. +#[derive(Debug, Clone, Default)] +pub struct WhereClauseOrdMap(BTreeMap>); + +impl WhereClauseOrdMap { + pub fn new() -> Self { + Self(BTreeMap::new()) + } + + pub fn from(tuples: &[(ElectoralLogVarCharColumn, SqlCompOperators)]) -> Self { + let mut map = WhereClauseOrdMap::new(); + for (key, value) in tuples { + map.insert(key.clone(), value.clone()); + } + map + } + + /// If the column already exists, the comparisson will be added to the existing ones. + /// Otherwise it will create the first one. + pub fn insert(&mut self, key: ElectoralLogVarCharColumn, value: SqlCompOperators) { + self.0 + .entry(key) + .and_modify(|vec| vec.push(value.clone())) + .or_insert(vec![value]); + } + + pub fn iter( + &self, + ) -> std::collections::btree_map::Iter> { + self.0.iter() + } + + /// If the first element of the map (first column in the where clause) has an index, it will be used. + /// This will match the longest possible index. + pub fn to_use_index_clause(&self) -> String { + let mut try_index_clause = String::from(""); + let mut last_index_clause_match = String::from(""); + for (col_name, _) in self.iter() { + if try_index_clause.is_empty() { + try_index_clause.push_str(&format!("({col_name}")); // For the contains() is important to mark with '(' the beginning of the index. + } else { + try_index_clause.push_str(&format!(", {col_name}")); + } + for index in MULTI_COLUMN_INDEXES { + if index.contains(&try_index_clause.as_str()) { + last_index_clause_match = format!("USE INDEX ON {index}"); + } + } + } + last_index_clause_match + } + + pub fn to_where_clause(&self) -> (String, Vec) { + let mut params = vec![]; + let mut where_clause = String::from(""); + for (col_name, comparissons) in self.iter() { + for (i, op) in comparissons.iter().enumerate() { + match op { + SqlCompOperators::In(values_vec) | SqlCompOperators::NotIn(values_vec) => { + let placeholders: Vec = values_vec + .iter() + .enumerate() + .map(|(j, _)| format!("@param_{col_name}{i}{j}")) + .collect(); + for (j, value) in values_vec.into_iter().enumerate() { + params.push(NamedParam { + name: format!("param_{col_name}{i}{j}"), + value: Some(SqlValue { + value: Some(Value::S(value.to_owned())), + }), + }); + } + if where_clause.is_empty() { + where_clause.push_str(&format!( + "{col_name} {op} ({})", + placeholders.join(", ") + )); + } else { + where_clause.push_str(&format!( + "AND {col_name} {op} ({})", + placeholders.join(", ") + )); + } + } + SqlCompOperators::Equal(value) + | SqlCompOperators::NotEqual(value) + | SqlCompOperators::GreaterThan(value) + | SqlCompOperators::LessThan(value) + | SqlCompOperators::GreaterThanOrEqual(value) + | SqlCompOperators::LessThanOrEqual(value) + | SqlCompOperators::Like(value) => { + if where_clause.is_empty() { + where_clause + .push_str(&format!("{col_name} {op} @param_{col_name}{i} ")); + } else { + where_clause + .push_str(&format!("AND {col_name} {op} @param_{col_name}{i} ")); + } + params.push(NamedParam { + name: format!("param_{col_name}{i}"), + value: Some(SqlValue { + value: Some(Value::S(value.to_owned())), + }), + }); + } + } + } + } + (where_clause, params) + } +} + +// Enumeration for the valid fields in the immudb table +#[derive(Debug, Deserialize, Hash, PartialEq, Eq, EnumString, Display, Clone)] +#[serde(rename_all = "snake_case")] +#[strum(serialize_all = "snake_case")] +pub enum OrderField { + Id, + Created, + StatementTimestamp, + StatementKind, + Message, + UserId, + Username, + BallotId, + SenderPk, + LogType, + EventType, + Description, + Version, +} + +// Enumeration for the valid order directions +#[derive(Debug, Deserialize, EnumString, Display, Clone)] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +pub enum OrderDirection { + Asc, + Desc, +} + +#[derive(Deserialize, Debug, Default, Clone)] +pub struct GetElectoralLogBody { + pub tenant_id: String, + pub election_event_id: String, + pub limit: Option, + pub offset: Option, + pub filter: Option>, + pub order_by: Option>, + pub election_id: Option, + pub area_ids: Option>, + pub only_with_user: Option, + pub statement_kind: Option, +} + +impl GetElectoralLogBody { + #[instrument(skip_all)] + pub fn get_min_max_ts(&self) -> Result<(Option, Option)> { + let mut min_ts: Option = None; + let mut max_ts: Option = None; + if let Some(filters_map) = &self.filter { + for (field, value) in filters_map.iter() { + match field { + OrderField::Created | OrderField::StatementTimestamp => { + let date_time_utc = DateTime::parse_from_rfc3339(&value) + .map_err(|err| anyhow!("{:?}", err))?; + let datetime = date_time_utc.with_timezone(&Utc); + let ts: i64 = datetime.timestamp(); + let ts_end: i64 = ts + 60; // Search along that minute, the second is not specified by the front. + min_ts = Some(ts); + max_ts = Some(ts_end); + } + _ => {} + } + } + } + + Ok((min_ts, max_ts)) + } + + #[instrument(skip_all)] + pub fn as_where_clause_map(&self) -> Result { + let mut cols_match_select = WhereClauseOrdMap::new(); + if let Some(filters_map) = &self.filter { + for (field, value) in filters_map.iter() { + match field { + OrderField::Id => {} // Why would someone filter the electoral log by id? + OrderField::SenderPk | OrderField::Username | OrderField::BallotId | OrderField::StatementKind | OrderField::Version => { // sql VARCHAR type + let variant = ElectoralLogVarCharColumn::from_str(field.to_string().as_str()).map_err(|_| anyhow!("Field not found"))?; + cols_match_select.insert( + variant, + SqlCompOperators::Equal(value.clone()), // Using 'Like' here would not scale for millions of entries, causing no response from immudb is some cases. + ); + } + OrderField::UserId => { + // insert user_id_mod + cols_match_select.insert( + ElectoralLogVarCharColumn::UserIdKey, + SqlCompOperators::Equal(value.clone().chars().take(3).collect()), + ); + let variant = ElectoralLogVarCharColumn::from_str(field.to_string().as_str()).map_err(|_| anyhow!("Field not found"))?; + cols_match_select.insert( + variant, + SqlCompOperators::Equal(value.clone()), + ); + } + OrderField::StatementTimestamp | OrderField::Created => {} // handled by `get_min_max_ts` + OrderField::EventType | OrderField::LogType | OrderField::Description // these have no column but are inside of Message + | OrderField::Message => {} // Message column is sql BLOB type and it´s encrypted so we can't filter it without expensive operations + } + } + } + if let Some(election_id) = &self.election_id { + if !election_id.is_empty() { + cols_match_select.insert( + ElectoralLogVarCharColumn::ElectionId, + SqlCompOperators::Equal(election_id.clone()), + ); + } + } + + if let Some(area_ids) = &self.area_ids { + if !area_ids.is_empty() { + // NOTE: `IN` values must be handled later in SQL building, here we just join them + cols_match_select.insert( + ElectoralLogVarCharColumn::AreaId, + SqlCompOperators::In(area_ids.clone()), // TODO: NullOrIn + ); + } + } + + if let Some(statement_kind) = &self.statement_kind { + cols_match_select.insert( + ElectoralLogVarCharColumn::StatementKind, + SqlCompOperators::Equal(statement_kind.to_string()), + ); + } + + Ok(cols_match_select) + } + + #[instrument] + pub fn as_cast_vote_count_and_select_clauses( + &self, + election_id: &str, + user_id: &str, + ballot_id_filter: &str, + ) -> (WhereClauseOrdMap, WhereClauseOrdMap) { + let cols_match_count = WhereClauseOrdMap::from(&[ + ( + ElectoralLogVarCharColumn::StatementKind, + SqlCompOperators::Equal(StatementType::CastVote.to_string()), + ), + ( + ElectoralLogVarCharColumn::ElectionId, + SqlCompOperators::Equal(election_id.to_string()), + ), + ]); + let mut cols_match_select = cols_match_count.clone(); + // Restrict the SQL query to user_id and ballot_id in case of filtering + if !ballot_id_filter.is_empty() { + cols_match_select.insert( + ElectoralLogVarCharColumn::UserIdKey, + SqlCompOperators::Equal(user_id.chars().take(3).collect()), + ); + cols_match_select.insert( + ElectoralLogVarCharColumn::UserId, + SqlCompOperators::Equal(user_id.to_string()), + ); + cols_match_select.insert( + ElectoralLogVarCharColumn::BallotId, + SqlCompOperators::Like(ballot_id_filter.to_string()), + ); + } + + (cols_match_count, cols_match_select) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ElectoralLogMessage { + pub id: i64, + pub created: i64, + pub sender_pk: String, + pub statement_timestamp: i64, + pub statement_kind: String, + pub message: Vec, + pub version: String, + pub user_id: Option, + pub username: Option, + pub election_id: Option, + pub area_id: Option, + pub ballot_id: Option, +} + +impl TryFrom<&Row> for ElectoralLogMessage { + type Error = anyhow::Error; + + fn try_from(row: &Row) -> Result { + let mut id = 0; + let mut created = 0; + let mut sender_pk = String::from(""); + let mut statement_timestamp = 0; + let mut statement_kind = String::from(""); + let mut message = vec![]; + let mut version = String::from(""); + let mut user_id: Option = None; + let mut username: Option = None; + let mut election_id: Option = None; + let mut area_id: Option = None; + let mut ballot_id: Option = None; + + for (column, value) in row.columns.iter().zip(row.values.iter()) { + // FIXME for some reason columns names appear with parentheses + let dot = column + .find('.') + .ok_or(anyhow!("invalid column found '{}'", column.as_str()))?; + let bare_column = &column[dot + 1..column.len() - 1]; + + match bare_column { + "id" => assign_value!(Value::N, value, id), + "created" => assign_value!(Value::Ts, value, created), + "sender_pk" => assign_value!(Value::S, value, sender_pk), + "statement_timestamp" => { + assign_value!(Value::Ts, value, statement_timestamp) + } + "statement_kind" => assign_value!(Value::S, value, statement_kind), + "message" => assign_value!(Value::Bs, value, message), + "version" => assign_value!(Value::S, value, version), + "user_id" => match value.value.as_ref() { + Some(Value::S(inner)) => user_id = Some(inner.clone()), + Some(Value::Null(_)) => user_id = None, + None => user_id = None, + _ => { + return Err(anyhow!( + "invalid column value for 'user_id': {:?}", + value.value.as_ref() + )) + } + }, + "username" => match value.value.as_ref() { + Some(Value::S(inner)) => username = Some(inner.clone()), + Some(Value::Null(_)) => username = None, + None => username = None, + _ => { + return Err(anyhow!( + "invalid column value for 'username': {:?}", + value.value.as_ref() + )) + } + }, + "election_id" => match value.value.as_ref() { + Some(Value::S(inner)) => election_id = Some(inner.clone()), + Some(Value::Null(_)) => election_id = None, + None => election_id = None, + _ => { + return Err(anyhow!( + "invalid column value for 'election_id': {:?}", + value.value.as_ref() + )) + } + }, + "area_id" => match value.value.as_ref() { + Some(Value::S(inner)) => area_id = Some(inner.clone()), + Some(Value::Null(_)) => area_id = None, + None => area_id = None, + _ => { + return Err(anyhow!( + "invalid column value for 'area_id': {:?}", + value.value.as_ref() + )) + } + }, + "ballot_id" => match value.value.as_ref() { + Some(Value::S(inner)) => ballot_id = Some(inner.clone()), + Some(Value::Null(_)) => ballot_id = None, + None => ballot_id = None, + _ => { + return Err(anyhow!( + "invalid column value for 'ballod_id': {:?}", + value.value.as_ref() + )) + } + }, + _ => return Err(anyhow!("invalid column found '{}'", bare_column)), + } + } + + Ok(ElectoralLogMessage { + id, + created, + sender_pk, + statement_timestamp, + statement_kind, + message, + version, + user_id, + username, + election_id, + area_id, + ballot_id, + }) + } +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct Aggregate { + pub count: i64, +} + +impl TryFrom<&Row> for Aggregate { + type Error = anyhow::Error; + + fn try_from(row: &Row) -> Result { + let mut count = 0; + + for (column, value) in row.columns.iter().zip(row.values.iter()) { + match column.as_str() { + _ => assign_value!(Value::N, value, count), + } + } + Ok(Aggregate { count }) + } +} diff --git a/packages/electoral-log/src/messages/message.rs b/packages/electoral-log/src/messages/message.rs index f27045dcd17..80bd822169d 100644 --- a/packages/electoral-log/src/messages/message.rs +++ b/packages/electoral-log/src/messages/message.rs @@ -2,11 +2,12 @@ // // SPDX-License-Identifier: AGPL-3.0-only -use crate::ElectoralLogMessage; +use crate::client::types::ElectoralLogMessage; use anyhow::Result; use borsh::{BorshDeserialize, BorshSerialize}; use serde::{Deserialize, Serialize}; +use strand::hash::STRAND_HASH_LENGTH_BYTES; use strand::serialization::StrandSerialize; use strand::signature::StrandSignature; use strand::signature::StrandSignaturePk; @@ -25,7 +26,7 @@ use std::fmt; /// a cross-event statement pub const GENERIC_EVENT: &'static str = "Generic Event"; -#[derive(BorshSerialize, BorshDeserialize, Serialize, Deserialize, std::fmt::Debug)] +#[derive(Clone, BorshSerialize, BorshDeserialize, Serialize, Deserialize, std::fmt::Debug)] pub struct Message { pub sender: Sender, pub sender_signature: StrandSignature, @@ -36,6 +37,7 @@ pub struct Message { pub username: Option, pub election_id: Option, pub area_id: Option, + pub ballot_id: Option, } impl fmt::Display for Message { @@ -60,7 +62,16 @@ impl Message { voter_username: Option, area_id: String, ) -> Result { - let body = StatementBody::CastVote(election.clone(), pseudonym_h, vote_h, ip, country); + let body = + StatementBody::CastVote(election.clone(), pseudonym_h, vote_h.clone(), ip, country); + let ballot_id: String = vote_h + .0 + .into_inner() + .iter() + .take(STRAND_HASH_LENGTH_BYTES / 2) + .map(|b| format!("{:02x}", b)) + .collect(); + Self::from_body( event, body, @@ -69,6 +80,7 @@ impl Message { voter_username.clone(), /* username */ election.0, Some(area_id), + Some(ballot_id), ) } @@ -92,6 +104,7 @@ impl Message { None, /* username */ election.0, Some(area_id), + None, ) } @@ -104,7 +117,7 @@ impl Message { username: Option, ) -> Result { let body = StatementBody::ElectionPublish(election.clone(), ballot_pub_id); - Self::from_body(event, body, sd, user_id, username, election.0, None) + Self::from_body(event, body, sd, user_id, username, election.0, None, None) } pub fn election_open_message( @@ -120,7 +133,7 @@ impl Message { Some(election) => { let body = StatementBody::ElectionVotingPeriodOpen(election.clone(), voting_channel); - Self::from_body(event, body, sd, user_id, username, election.0, None) + Self::from_body(event, body, sd, user_id, username, election.0, None, None) } None => { let body = StatementBody::ElectionEventVotingPeriodOpen( @@ -128,7 +141,7 @@ impl Message { ElectionsIdsString(election_ids.clone()), voting_channel, ); - Self::from_body(event, body, sd, user_id, username, None, None) + Self::from_body(event, body, sd, user_id, username, None, None, None) } } } @@ -145,12 +158,12 @@ impl Message { Some(election) => { let body = StatementBody::ElectionVotingPeriodPause(election.clone(), voting_channel); - Self::from_body(event, body, sd, user_id, username, election.0, None) + Self::from_body(event, body, sd, user_id, username, election.0, None, None) } None => { let body = StatementBody::ElectionEventVotingPeriodPause(event.clone(), voting_channel); - Self::from_body(event, body, sd, user_id, username, None, None) + Self::from_body(event, body, sd, user_id, username, None, None, None) } } } @@ -168,7 +181,7 @@ impl Message { Some(election) => { let body = StatementBody::ElectionVotingPeriodClose(election.clone(), voting_channel); - Self::from_body(event, body, sd, user_id, username, election.0, None) + Self::from_body(event, body, sd, user_id, username, election.0, None, None) } None => { let body = StatementBody::ElectionEventVotingPeriodClose( @@ -176,7 +189,7 @@ impl Message { ElectionsIdsString(election_ids.clone()), voting_channel, ); - Self::from_body(event, body, sd, user_id, username, None, None) + Self::from_body(event, body, sd, user_id, username, None, None, None) } } } @@ -191,7 +204,7 @@ impl Message { area_id: Option, ) -> Result { let body = StatementBody::KeycloakUserEvent(error, event_type); - Self::from_body(event, body, sd, user_id, username, None, area_id) + Self::from_body(event, body, sd, user_id, username, None, area_id, None) } pub fn keygen_message( @@ -202,7 +215,7 @@ impl Message { election_id: Option, ) -> Result { let body = StatementBody::KeyGeneration; - Self::from_body(event, body, sd, user_id, username, election_id, None) + Self::from_body(event, body, sd, user_id, username, election_id, None, None) } pub fn key_insertion_start( @@ -213,7 +226,16 @@ impl Message { elections_ids: Option, ) -> Result { let body = StatementBody::KeyInsertionStart; - Self::from_body(event, body, sd, user_id, username, elections_ids, None) + Self::from_body( + event, + body, + sd, + user_id, + username, + elections_ids, + None, + None, + ) } pub fn key_insertion_message( @@ -225,7 +247,16 @@ impl Message { elections_ids: Option, ) -> Result { let body = StatementBody::KeyInsertionCeremony(trustee_name); - Self::from_body(event, body, sd, user_id, username, elections_ids, None) + Self::from_body( + event, + body, + sd, + user_id, + username, + elections_ids, + None, + None, + ) } pub fn tally_open_message( @@ -236,7 +267,7 @@ impl Message { username: Option, ) -> Result { let body = StatementBody::TallyOpen(election.clone()); - Self::from_body(event, body, sd, user_id, username, election.0, None) + Self::from_body(event, body, sd, user_id, username, election.0, None, None) } pub fn tally_close_message( @@ -247,7 +278,7 @@ impl Message { username: Option, ) -> Result { let body = StatementBody::TallyClose(election); - Self::from_body(event, body, sd, user_id, username, None, None) + Self::from_body(event, body, sd, user_id, username, None, None, None) } pub fn send_template( @@ -260,7 +291,7 @@ impl Message { area_id: Option, ) -> Result { let body = StatementBody::SendCommunications(message); - Self::from_body(event, body, sd, user_id, username, None, area_id) + Self::from_body(event, body, sd, user_id, username, None, area_id, None) } pub fn voter_public_key_message( @@ -274,7 +305,7 @@ impl Message { area_id: Option, ) -> Result { let body = StatementBody::VoterPublicKey(tenant_id, event.clone(), user_hash, pk); - Self::from_body(event, body, sd, user_id, username, None, area_id) + Self::from_body(event, body, sd, user_id, username, None, area_id, None) } pub fn admin_public_key_message( @@ -289,7 +320,16 @@ impl Message { let body = StatementBody::AdminPublicKey(tenant_id, user_id.clone(), pk); let event = EventIdString(GENERIC_EVENT.to_string()); - Self::from_body(event, body, sd, user_id, username, elections_ids, area_id) + Self::from_body( + event, + body, + sd, + user_id, + username, + elections_ids, + area_id, + None, + ) } fn from_body( @@ -300,6 +340,7 @@ impl Message { username: Option, election_id: Option, area_id: Option, + ballot_id: Option, ) -> Result { let head = StatementHead::from_body(event, &body); let statement = Statement::new(head, body); @@ -314,6 +355,7 @@ impl Message { username, election_id, area_id, + ballot_id, ) } @@ -327,6 +369,7 @@ impl Message { username: Option, election_id: Option, area_id: Option, + ballot_id: Option, ) -> Result { let bytes = statement.strand_serialize()?; let sender_signature: StrandSignature = sender_sk.sign(&bytes)?; @@ -344,6 +387,7 @@ impl Message { username, election_id, area_id, + ballot_id, }) } @@ -372,6 +416,7 @@ impl TryFrom<&Message> for ElectoralLogMessage { username: message.username.clone(), election_id: message.election_id.clone(), area_id: message.area_id.clone(), + ballot_id: message.ballot_id.clone(), }) } } diff --git a/packages/electoral-log/src/messages/statement.rs b/packages/electoral-log/src/messages/statement.rs index 0c351725756..4f0503a9e89 100644 --- a/packages/electoral-log/src/messages/statement.rs +++ b/packages/electoral-log/src/messages/statement.rs @@ -10,7 +10,7 @@ use strum_macros::Display; use crate::messages::newtypes::*; use tracing::info; -#[derive(BorshSerialize, BorshDeserialize, Deserialize, Serialize, Debug)] +#[derive(BorshSerialize, BorshDeserialize, Deserialize, Serialize, Debug, Clone)] pub struct Statement { pub head: StatementHead, pub body: StatementBody, @@ -173,7 +173,7 @@ impl StatementHead { } } -#[derive(BorshSerialize, BorshDeserialize, Deserialize, Serialize, Debug)] +#[derive(BorshSerialize, BorshDeserialize, Deserialize, Serialize, Debug, Clone)] pub enum StatementBody { // NOT IMPLEMENTED YET, but please feel free // "Emisión de voto (sólo como registro que el sistema almacenó correctamente el voto) @@ -252,6 +252,7 @@ pub enum StatementBody { AdminPublicKey(TenantIdString, Option, PublicKeyDerB64), } +// Note: When creating new variants, consider that the length limit STATEMENT_KIND_VARCHAR_LENGTH is 40. #[derive(BorshSerialize, BorshDeserialize, Display, Deserialize, Serialize, Debug, Clone)] pub enum StatementType { Unknown, diff --git a/packages/harvest/src/main.rs b/packages/harvest/src/main.rs index e3fa2bb7535..2ed45a86b52 100644 --- a/packages/harvest/src/main.rs +++ b/packages/harvest/src/main.rs @@ -46,6 +46,12 @@ async fn rocket() -> _ { routes::api_datafix::replace_pin, ], ) + .mount( + "/voting-portal", + routes![ + routes::voter_electoral_log::list_cast_vote_messages, + ], + ) .mount( "/", routes![ diff --git a/packages/harvest/src/routes/ballot_publication.rs b/packages/harvest/src/routes/ballot_publication.rs index 0def2479ef4..eb99a195b4f 100644 --- a/packages/harvest/src/routes/ballot_publication.rs +++ b/packages/harvest/src/routes/ballot_publication.rs @@ -6,7 +6,6 @@ use anyhow::Result; use deadpool_postgres::Client as DbClient; use rocket::http::Status; use rocket::serde::json::Json; -use sequent_core::types::hasura; use sequent_core::types::permissions::Permissions; use sequent_core::{ ballot::{ElectionEventPresentation, LockedDown}, diff --git a/packages/harvest/src/routes/electoral_log.rs b/packages/harvest/src/routes/electoral_log.rs index 20ae1fb8927..562893195ee 100644 --- a/packages/harvest/src/routes/electoral_log.rs +++ b/packages/harvest/src/routes/electoral_log.rs @@ -4,35 +4,120 @@ // SPDX-License-Identifier: AGPL-3.0-only use crate::services::authorization::authorize; +use crate::types::resources::TotalAggregate; use anyhow::{anyhow, Context, Result}; use deadpool_postgres::Client as DbClient; +use electoral_log::client::types::*; use rocket::http::Status; use rocket::serde::json::Json; use sequent_core::services::jwt::JwtClaims; +use sequent_core::services::keycloak::get_event_realm; use sequent_core::types::permissions::Permissions; -use serde::{Deserialize, Serialize}; -use tracing::instrument; +use tracing::{info, instrument}; +use windmill::services::database::get_keycloak_pool; use windmill::services::electoral_log::{ - list_electoral_log as get_logs, ElectoralLogRow, GetElectoralLogBody, + count_electoral_log, list_electoral_log as windmill_list_electoral_log, + ElectoralLogRow, }; +use windmill::services::users::get_users_by_username; use windmill::types::resources::DataList; -#[instrument] +#[instrument(skip(claims))] #[post("/immudb/electoral-log", format = "json", data = "")] pub async fn list_electoral_log( body: Json, claims: JwtClaims, ) -> Result>, (Status, String)> { - let input = body.into_inner(); + let mut input = body.into_inner(); authorize( &claims, true, Some(input.tenant_id.clone()), vec![Permissions::LOGS_READ], )?; - let ret_val = get_logs(input) + + // If there is username but no user_id in the filter, fill the user_id to + // inprove performance. + if let Some(filter) = &mut input.filter { + if let (Some(username), None) = ( + filter.get(&OrderField::Username), + filter.get(&OrderField::UserId), + ) { + match get_user_id( + &input.tenant_id, + &input.election_event_id, + username, + ) + .await + { + Ok(Some(user_id)) => { + filter.insert(OrderField::UserId, user_id); + } + Ok(None) => { + return Ok(Json(DataList::default())); + } + Err(e) => { + return Err((Status::InternalServerError, e.to_string())); + } + } + } + } + + let (data_res, count_res) = tokio::join!( + windmill_list_electoral_log(input.clone()), + count_electoral_log(input) + ); + + let mut data = data_res.map_err(|e| { + ( + Status::InternalServerError, + format!("Eror listing electoral log: {e:?}"), + ) + })?; + data.total.aggregate.count = count_res.map_err(|e| { + ( + Status::InternalServerError, + format!("Error counting electoral log: {e:?}"), + ) + })?; + + Ok(Json(data)) +} + +/// Get user id by username +#[instrument(err)] +pub async fn get_user_id( + tenant_id: &str, + election_event_id: &str, + username: &str, +) -> Result> { + let realm = get_event_realm(tenant_id, election_event_id); + let mut keycloak_db_client: DbClient = get_keycloak_pool() + .await + .get() .await - .map_err(|e| (Status::InternalServerError, format!("{:?}", e)))?; + .map_err(|e| anyhow!("Error getting keycloak client: {e:?}"))?; + + let keycloak_transaction = keycloak_db_client + .transaction() + .await + .map_err(|e| anyhow!("Error getting keycloak transaction: {e:?}"))?; + + let user_ids = + get_users_by_username(&keycloak_transaction, &realm, username) + .await + .map_err(|e| anyhow!("Error getting users by username: {e:?}"))?; - Ok(Json(ret_val)) + match user_ids.len() { + 0 => { + info!("Could not get users by username: Not Found"); + return Ok(None); + } + 1 => Ok(Some(user_ids[0].clone())), + _ => { + return Err(anyhow!( + "Error getting users by username: Multiple users Found" + )); + } + } } diff --git a/packages/harvest/src/routes/immudb_log_audit.rs b/packages/harvest/src/routes/immudb_log_audit.rs index 2aaa8a44e93..fa8457a23cb 100644 --- a/packages/harvest/src/routes/immudb_log_audit.rs +++ b/packages/harvest/src/routes/immudb_log_audit.rs @@ -4,11 +4,10 @@ // SPDX-License-Identifier: AGPL-3.0-only use crate::services::authorization::authorize; -use crate::types::resources::{ - Aggregate, DataList, OrderDirection, TotalAggregate, -}; +use crate::types::resources::{Aggregate, DataList, TotalAggregate}; use anyhow::{anyhow, Context, Result}; use electoral_log::assign_value; +use electoral_log::client::types::OrderDirection; use immudb_rs::{sql_value::Value, Client, NamedParam, Row, SqlValue}; use rocket::http::Status; use rocket::response::Debug; diff --git a/packages/harvest/src/routes/mod.rs b/packages/harvest/src/routes/mod.rs index 9af22abe5e2..c036c801ed1 100644 --- a/packages/harvest/src/routes/mod.rs +++ b/packages/harvest/src/routes/mod.rs @@ -48,4 +48,5 @@ pub mod templates; pub mod trustees; pub mod upload_document; pub mod users; +pub mod voter_electoral_log; pub mod voting_status; diff --git a/packages/harvest/src/routes/voter_electoral_log.rs b/packages/harvest/src/routes/voter_electoral_log.rs new file mode 100644 index 00000000000..29762ba190c --- /dev/null +++ b/packages/harvest/src/routes/voter_electoral_log.rs @@ -0,0 +1,119 @@ +// SPDX-FileCopyrightText: 2023 Felix Robles +// SPDX-FileCopyrightText: 2023 Eduardo Robles +// +// SPDX-License-Identifier: AGPL-3.0-only + +use crate::services::authorization::authorize_voter_election; +use crate::types::error_response::{ErrorCode, ErrorResponse, JsonError}; +use anyhow::Result; +use electoral_log::client::types::*; +use rocket::http::Status; +use rocket::serde::json::Json; +use sequent_core::ballot::ShowCastVoteLogs; +use sequent_core::services::jwt::JwtClaims; +use sequent_core::types::hasura::core::ElectionEvent; +use sequent_core::types::permissions::VoterPermissions; +use serde::Deserialize; +use std::collections::HashMap; +use tracing::instrument; +use windmill::postgres::election_event::get_election_event_by_id; +use windmill::services::electoral_log::list_cast_vote_messages_and_count; +use windmill::services::electoral_log::CastVoteMessagesOutput; +use windmill::services::providers::transactions_provider::provide_hasura_transaction; + +#[derive(Deserialize, Debug)] +pub struct CastVoteMessagesInput { + pub tenant_id: String, + pub election_event_id: String, + pub election_id: Option, + pub ballot_id: String, + pub limit: Option, + pub offset: Option, + pub order_by: Option>, +} + +#[instrument] +#[post("/immudb/list-cast-vote-messages", format = "json", data = "")] +pub async fn list_cast_vote_messages( + body: Json, + claims: JwtClaims, +) -> Result, JsonError> { + let input = body.into_inner(); + // let election_id = input.election_id.as_deref().unwrap_or_default(); + let election_id = input.election_id.clone().unwrap_or_default(); // TODO: Temporary till merging the ballot performace inprovements. + let username = claims.preferred_username.clone().unwrap_or_default(); + let user_id = claims.hasura_claims.user_id.clone(); + + // Check auth. + let (_area_id, _voting_channel) = authorize_voter_election( + &claims, + vec![VoterPermissions::CAST_VOTE], + &election_id, + ) + .map_err(|e| { + ErrorResponse::new( + Status::Unauthorized, + &format!("{:?}", e), + ErrorCode::Unauthorized, + ) + })?; // TODO: Temporary till merging the ballot performace inprovements. + + // Check that the policy is enabled + provide_hasura_transaction(|hasura_transaction| { + let tenant_id = claims.hasura_claims.tenant_id.clone(); + let election_event_id = input.election_event_id.clone(); + Box::pin(async move { + let election_event: ElectionEvent = get_election_event_by_id( + hasura_transaction, + &tenant_id, + &election_event_id, + ) + .await?; + let policy = election_event.presentation.and_then( + |val| val.get("show_cast_vote_logs") + .map(|value| serde_json::from_value::(value.clone()).unwrap_or_default()) + ).unwrap_or_default(); + match policy { + ShowCastVoteLogs::ShowLogsTab => { + Ok(()) + } + ShowCastVoteLogs::HideLogsTab => { + Err(anyhow::anyhow!(ShowCastVoteLogs::HideLogsTab.to_string())) + } + } + }) + }) + .await + .map_err(|error| { + ErrorResponse::new( + Status::Forbidden, + &format!("Failed to confirm that the show_cast_vote_logs policy is enabled: {error:?}"), + ErrorCode::ConfirmPolicyShowCastVoteLogsFailed, + ) + })?; + + let ballot_id = input.ballot_id.as_str(); + let elog_input = GetElectoralLogBody { + tenant_id: input.tenant_id, + election_event_id: input.election_event_id, + limit: input.limit, + offset: input.offset, + order_by: input.order_by, + election_id: input.election_id, + ..Default::default() + }; + + let ret_val = list_cast_vote_messages_and_count( + elog_input, ballot_id, &user_id, &username, + ) + .await + .map_err(|e| { + ErrorResponse::new( + Status::InternalServerError, + &format!("Error to list cast vote messages: {e:?}"), + ErrorCode::InternalServerError, + ) + })?; + + Ok(Json(ret_val)) +} diff --git a/packages/harvest/src/types/error_response.rs b/packages/harvest/src/types/error_response.rs index 00d1241db68..e945a576c22 100644 --- a/packages/harvest/src/types/error_response.rs +++ b/packages/harvest/src/types/error_response.rs @@ -30,6 +30,7 @@ pub enum ErrorCode { UuidParseFailed, UnknownError, InvalidEventProcessor, + ConfirmPolicyShowCastVoteLogsFailed, // Add any other needed error codes } diff --git a/packages/harvest/src/types/resources.rs b/packages/harvest/src/types/resources.rs index 19c526e4a9f..686c79f158a 100644 --- a/packages/harvest/src/types/resources.rs +++ b/packages/harvest/src/types/resources.rs @@ -18,15 +18,6 @@ pub struct TotalAggregate { pub aggregate: Aggregate, } -// Enumeration for the valid order directions -#[derive(Debug, Deserialize, EnumString, Display)] -#[serde(rename_all = "lowercase")] -#[strum(serialize_all = "lowercase")] -pub enum OrderDirection { - Asc, - Desc, -} - #[derive(Deserialize, Debug)] pub struct SortPayload { pub field: String, diff --git a/packages/immu-board/src/board_client.rs b/packages/immu-board/src/board_client.rs index cdf4b7afbe5..c4fa5b999d0 100644 --- a/packages/immu-board/src/board_client.rs +++ b/packages/immu-board/src/board_client.rs @@ -3,8 +3,7 @@ // SPDX-License-Identifier: AGPL-3.0-only use anyhow::{anyhow, Context, Result}; -use log::info; -use tracing::{event, instrument, Level}; +use tracing::{info, instrument}; use immudb_rs::{sql_value::Value, Client, NamedParam, Row, SqlValue, TxMode}; use std::fmt::Debug; @@ -353,17 +352,18 @@ impl BoardClient { /// Creates the requested immudb database, only if it doesn't exist. It also creates /// the requested tables if they don't exist. + #[instrument(skip(self))] async fn upsert_database(&mut self, database_name: &str, tables: &str) -> Result<()> { // create database if it doesn't exist if !self.client.has_database(database_name).await? { self.client.create_database(database_name).await?; - event!(Level::INFO, "Database created!"); + info!("Database created!"); }; self.client.use_database(database_name).await?; // List tables and create them if missing if !self.client.has_tables().await? { - event!(Level::INFO, "no tables! let's create them"); + info!("no tables! let's create them"); self.client.sql_exec(&tables, vec![]).await?; } Ok(()) diff --git a/packages/sequent-core/Cargo.toml b/packages/sequent-core/Cargo.toml index a099c11f7ff..51631a532d4 100644 --- a/packages/sequent-core/Cargo.toml +++ b/packages/sequent-core/Cargo.toml @@ -26,7 +26,7 @@ serde = { version = "1.0.190", features = ["derive"] } serde_json = "1.0" serde_path_to_error = "0.1" # borsh = "0.9.3" -borsh = { version = "=1.5.1", features = ["derive"] } +borsh = { version = "1.5", features = ["derive"] } # wasm wasm-bindgen = {version = "=0.2.100", features = ['serde-serialize'], optional = true} serde-wasm-bindgen = { version = "0.4", optional = true } diff --git a/packages/sequent-core/src/ballot.rs b/packages/sequent-core/src/ballot.rs index 4f54330188f..72f0f81dd2e 100644 --- a/packages/sequent-core/src/ballot.rs +++ b/packages/sequent-core/src/ballot.rs @@ -463,6 +463,30 @@ pub enum AuditButtonCfg { SHOW_IN_HELP, } +#[derive( + Debug, + BorshSerialize, + BorshDeserialize, + Serialize, + Deserialize, + PartialEq, + Eq, + JsonSchema, + Clone, + EnumString, + Display, + Default, +)] +pub enum ShowCastVoteLogs { + #[strum(serialize = "show-logs-tab")] + #[serde(rename = "show-logs-tab")] + ShowLogsTab, + #[strum(serialize = "hide-logs-tab")] + #[serde(rename = "hide-logs-tab")] + #[default] + HideLogsTab, +} + #[derive( Debug, BorshSerialize, @@ -673,6 +697,7 @@ pub struct ElectionEventPresentation { pub css: Option, pub skip_election_list: Option, pub show_user_profile: Option, // default is true + pub show_cast_vote_logs: Option, pub elections_order: Option, pub voting_portal_countdown_policy: Option, pub custom_urls: Option, diff --git a/packages/step-cli/src/commands/create_electoral_logs.rs b/packages/step-cli/src/commands/create_electoral_logs.rs index 8077bd7a93a..85126693fda 100644 --- a/packages/step-cli/src/commands/create_electoral_logs.rs +++ b/packages/step-cli/src/commands/create_electoral_logs.rs @@ -6,12 +6,12 @@ use crate::utils::read_config::load_external_config; use anyhow::{anyhow, Context, Result}; use chrono::Utc; use clap::Args; +use electoral_log::client::types::ElectoralLogMessage; use electoral_log::messages::message::{Message, Sender}; use electoral_log::messages::newtypes::EventIdString; use electoral_log::messages::statement::{ Statement, StatementBody, StatementEventType, StatementHead, StatementLogType, StatementType, }; -use electoral_log::ElectoralLogMessage; use fake::faker::internet::raw::Username; use fake::locales::EN; use fake::Fake; @@ -94,6 +94,7 @@ impl CreateElectoralLogs { username: username.clone(), election_id: Some(election_id.to_string()), area_id: Some(area_id.to_string()), + ballot_id: None, }; let board_message: ElectoralLogMessage = message.try_into().with_context(|| "")?; diff --git a/packages/step-cli/src/commands/export_cast_votes.rs b/packages/step-cli/src/commands/export_cast_votes.rs index 679b7390920..b3ec08a3f53 100644 --- a/packages/step-cli/src/commands/export_cast_votes.rs +++ b/packages/step-cli/src/commands/export_cast_votes.rs @@ -9,20 +9,24 @@ use base64::engine::general_purpose; use base64::Engine; use clap::Args; use csv::WriterBuilder; +use electoral_log::client::types::{ + ElectoralLogVarCharColumn, SqlCompOperators, WhereClauseOrdMap, +}; use electoral_log::messages::message::Message; use electoral_log::messages::newtypes::ElectionIdString; -use electoral_log::messages::statement::StatementBody; +use electoral_log::messages::statement::{StatementBody, StatementType}; use electoral_log::BoardClient; use sequent_core::encrypt::shorten_hash; use serde::Serialize; use serde_json::Value; +use std::collections::BTreeMap; +use std::collections::HashMap; use std::env; use std::fs::File; use strand::serialization::StrandDeserialize; use tokio_postgres::Transaction; use uuid::Uuid; use windmill::services::providers::transactions_provider::provide_hasura_transaction; - #[derive(Serialize)] struct Record { created: i64, @@ -77,9 +81,22 @@ impl ExportCastVotes { .await .map_err(|err| anyhow!("Failed to create the client: {:?}", err))?; + let cols_match = WhereClauseOrdMap::from(&[( + ElectoralLogVarCharColumn::StatementKind, + SqlCompOperators::Equal(StatementType::CastVote.to_string()), + )]); + let order_by: Option> = None; println!("Getting messages"); let electoral_log_messages = client - .get_electoral_log_messages_filtered(&self.board_db, "CastVote", None, None, None) + .get_electoral_log_messages_filtered( + &self.board_db, + Some(cols_match), + None, + None, + None, + None, + order_by, + ) .await .map_err(|err| anyhow!("Failed to get filtered messages: {:?}", err))?; diff --git a/packages/strand/Cargo.toml b/packages/strand/Cargo.toml index 1ceebb4c831..d0f4b29d245 100644 --- a/packages/strand/Cargo.toml +++ b/packages/strand/Cargo.toml @@ -63,7 +63,7 @@ sha3 = "0.10" # Serialization # borsh = "0.9.3" -borsh = { version = "=1.5.1", features = ["derive"] } +borsh = { version = "1.5", features = ["derive"] } base64 = "0.22.1" # RNG diff --git a/packages/ui-core/src/types/ElectionEventPresentation.ts b/packages/ui-core/src/types/ElectionEventPresentation.ts index 492b6390d0f..82bc1c05815 100644 --- a/packages/ui-core/src/types/ElectionEventPresentation.ts +++ b/packages/ui-core/src/types/ElectionEventPresentation.ts @@ -20,6 +20,11 @@ export enum EVoterSigningPolicy { WITH_SIGNATURE = "with-signature", } +export enum EShowCastVoteLogsPolicy { + SHOW_LOGS_TAB = "show-logs-tab", + HIDE_LOGS_TAB = "hide-logs-tab", +} + export enum ElectionsOrder { RANDOM = "random", CUSTOM = "custom", diff --git a/packages/voting-portal/graphql.schema.json b/packages/voting-portal/graphql.schema.json index a292d2b1abb..3c74b892a7f 100644 --- a/packages/voting-portal/graphql.schema.json +++ b/packages/voting-portal/graphql.schema.json @@ -462,6 +462,81 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "OBJECT", + "name": "CastVoteEntry", + "description": null, + "fields": [ + { + "name": "ballot_id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "statement_kind", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "statement_timestamp", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "username", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, { "kind": "OBJECT", "name": "CastVotesByIp", @@ -2486,6 +2561,18 @@ "defaultValue": null, "isDeprecated": false, "deprecationReason": null + }, + { + "name": "username", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null } ], "interfaces": null, @@ -2498,6 +2585,18 @@ "description": null, "fields": null, "inputFields": [ + { + "name": "ballot_id", + "description": null, + "type": { + "kind": "ENUM", + "name": "OrderDirection", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "created", "description": null, @@ -2557,6 +2656,18 @@ "defaultValue": null, "isDeprecated": false, "deprecationReason": null + }, + { + "name": "username", + "description": null, + "type": { + "kind": "ENUM", + "name": "OrderDirection", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null } ], "interfaces": null, @@ -5898,6 +6009,53 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "OBJECT", + "name": "ListCastVoteMessagesOutput", + "description": null, + "fields": [ + { + "name": "list", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CastVoteEntry", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "total", + "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": "ListKeysCeremonyOutput", @@ -29424,6 +29582,115 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "list_cast_vote_messages", + "description": "List electoral log entries of statement_kind CastVote", + "args": [ + { + "name": "ballot_id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "election_event_id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "election_id", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "limit", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "offset", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "order_by", + "description": null, + "type": { + "kind": "INPUT_OBJECT", + "name": "ElectoralLogOrderBy", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "tenant_id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "ListCastVoteMessagesOutput", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "list_keys_ceremony", "description": null, diff --git a/packages/voting-portal/src/gql/gql.ts b/packages/voting-portal/src/gql/gql.ts index 67c3a2e0d0c..a8216caaf81 100644 --- a/packages/voting-portal/src/gql/gql.ts +++ b/packages/voting-portal/src/gql/gql.ts @@ -23,6 +23,7 @@ const documents = { "\n query GetElections($electionIds: [uuid!]!) {\n sequent_backend_election(where: {id: {_in: $electionIds}}) {\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 name\n num_allowed_revotes\n presentation\n spoil_ballot_option\n status\n tenant_id\n alias\n }\n }\n": types.GetElectionsDocument, "\n query GetSupportMaterials($electionEventId: uuid!, $tenantId: uuid!) {\n sequent_backend_support_material(\n where: {\n _and: {\n is_hidden: {_eq: false}\n election_event_id: {_eq: $electionEventId}\n tenant_id: {_eq: $tenantId}\n }\n }\n ) {\n data\n document_id\n id\n annotations\n created_at\n election_event_id\n kind\n labels\n last_updated_at\n tenant_id\n }\n }\n": types.GetSupportMaterialsDocument, "\n mutation InsertCastVote($electionId: uuid!, $ballotId: String!, $content: String!) {\n insert_cast_vote(election_id: $electionId, ballot_id: $ballotId, content: $content) {\n id\n ballot_id\n election_id\n election_event_id\n tenant_id\n election_id\n area_id\n created_at\n last_updated_at\n labels\n annotations\n content\n cast_ballot_signature\n voter_id_string\n election_event_id\n }\n }\n": types.InsertCastVoteDocument, + "\n query listCastVoteMessages(\n $tenantId: String!\n $electionEventId: String!\n $electionId: String\n $ballotId: String!\n $limit: Int\n $offset: Int\n $orderBy: ElectoralLogOrderBy\n ) {\n list_cast_vote_messages(\n tenant_id: $tenantId\n election_event_id: $electionEventId\n election_id: $electionId\n ballot_id: $ballotId\n limit: $limit\n offset: $offset\n order_by: $orderBy\n ) {\n list {\n statement_timestamp\n statement_kind\n ballot_id\n username\n }\n total\n }\n }\n": types.ListCastVoteMessagesDocument, }; /** @@ -79,6 +80,10 @@ export function graphql(source: "\n query GetSupportMaterials($electionEventI * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ export function graphql(source: "\n mutation InsertCastVote($electionId: uuid!, $ballotId: String!, $content: String!) {\n insert_cast_vote(election_id: $electionId, ballot_id: $ballotId, content: $content) {\n id\n ballot_id\n election_id\n election_event_id\n tenant_id\n election_id\n area_id\n created_at\n last_updated_at\n labels\n annotations\n content\n cast_ballot_signature\n voter_id_string\n election_event_id\n }\n }\n"): (typeof documents)["\n mutation InsertCastVote($electionId: uuid!, $ballotId: String!, $content: String!) {\n insert_cast_vote(election_id: $electionId, ballot_id: $ballotId, content: $content) {\n id\n ballot_id\n election_id\n election_event_id\n tenant_id\n election_id\n area_id\n created_at\n last_updated_at\n labels\n annotations\n content\n cast_ballot_signature\n voter_id_string\n election_event_id\n }\n }\n"]; +/** + * 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 listCastVoteMessages(\n $tenantId: String!\n $electionEventId: String!\n $electionId: String\n $ballotId: String!\n $limit: Int\n $offset: Int\n $orderBy: ElectoralLogOrderBy\n ) {\n list_cast_vote_messages(\n tenant_id: $tenantId\n election_event_id: $electionEventId\n election_id: $electionId\n ballot_id: $ballotId\n limit: $limit\n offset: $offset\n order_by: $orderBy\n ) {\n list {\n statement_timestamp\n statement_kind\n ballot_id\n username\n }\n total\n }\n }\n"): (typeof documents)["\n query listCastVoteMessages(\n $tenantId: String!\n $electionEventId: String!\n $electionId: String\n $ballotId: String!\n $limit: Int\n $offset: Int\n $orderBy: ElectoralLogOrderBy\n ) {\n list_cast_vote_messages(\n tenant_id: $tenantId\n election_event_id: $electionEventId\n election_id: $electionId\n ballot_id: $ballotId\n limit: $limit\n offset: $offset\n order_by: $orderBy\n ) {\n list {\n statement_timestamp\n statement_kind\n ballot_id\n username\n }\n total\n }\n }\n"]; export function graphql(source: string) { return (documents as any)[source] ?? {}; diff --git a/packages/voting-portal/src/gql/graphql.ts b/packages/voting-portal/src/gql/graphql.ts index 26d269ee64a..eee6232c8eb 100644 --- a/packages/voting-portal/src/gql/graphql.ts +++ b/packages/voting-portal/src/gql/graphql.ts @@ -74,6 +74,14 @@ export type Boolean_Comparison_Exp = { _nin?: InputMaybe>; }; +export type CastVoteEntry = { + __typename?: 'CastVoteEntry'; + ballot_id: Scalars['String']['output']; + statement_kind: Scalars['String']['output']; + statement_timestamp: Scalars['Int']['output']; + username: Scalars['String']['output']; +}; + export type CastVotesByIp = { __typename?: 'CastVotesByIp'; country?: Maybe; @@ -296,14 +304,17 @@ export type ElectoralLogFilter = { statement_kind?: InputMaybe; statement_timestamp?: InputMaybe; user_id?: InputMaybe; + username?: InputMaybe; }; export type ElectoralLogOrderBy = { + ballot_id?: InputMaybe; created?: InputMaybe; id?: InputMaybe; statement_kind?: InputMaybe; statement_timestamp?: InputMaybe; user_id?: InputMaybe; + username?: InputMaybe; }; export type ElectoralLogRow = { @@ -678,6 +689,12 @@ export type LimitAccessByCountriesOutput = { success?: Maybe; }; +export type ListCastVoteMessagesOutput = { + __typename?: 'ListCastVoteMessagesOutput'; + list: Array>; + total: Scalars['Int']['output']; +}; + export type ListKeysCeremonyOutput = { __typename?: 'ListKeysCeremonyOutput'; items: Array; @@ -4309,6 +4326,8 @@ export type Query_Root = { listElectoralLog?: Maybe; /** List PostgreSQL audit logs */ listPgaudit?: Maybe; + /** List electoral log entries of statement_kind CastVote */ + list_cast_vote_messages?: Maybe; list_keys_ceremony?: Maybe; list_user_roles: Array; /** log an event in immudb */ @@ -4608,6 +4627,17 @@ export type Query_RootListPgauditArgs = { }; +export type Query_RootList_Cast_Vote_MessagesArgs = { + ballot_id: Scalars['String']['input']; + election_event_id: Scalars['String']['input']; + election_id?: InputMaybe; + limit?: InputMaybe; + offset?: InputMaybe; + order_by?: InputMaybe; + tenant_id: Scalars['String']['input']; +}; + + export type Query_RootList_Keys_CeremonyArgs = { election_event_id: Scalars['String']['input']; }; @@ -20680,6 +20710,19 @@ export type InsertCastVoteMutationVariables = Exact<{ export type InsertCastVoteMutation = { __typename?: 'mutation_root', insert_cast_vote?: { __typename?: 'InsertCastVoteOutput', id: any, ballot_id?: string | null, election_id: any, election_event_id: any, tenant_id: any, area_id: any, created_at?: any | null, last_updated_at?: any | null, labels?: any | null, annotations?: any | null, content?: string | null, cast_ballot_signature: any, voter_id_string?: string | null } | null }; +export type ListCastVoteMessagesQueryVariables = Exact<{ + tenantId: Scalars['String']['input']; + electionEventId: Scalars['String']['input']; + electionId?: InputMaybe; + ballotId: Scalars['String']['input']; + limit?: InputMaybe; + offset?: InputMaybe; + orderBy?: InputMaybe; +}>; + + +export type ListCastVoteMessagesQuery = { __typename?: 'query_root', list_cast_vote_messages?: { __typename?: 'ListCastVoteMessagesOutput', total: number, list: Array<{ __typename?: 'CastVoteEntry', statement_timestamp: number, statement_kind: string, ballot_id: string, username: string } | null> } | null }; + export const CreateBallotReceiptDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"createBallotReceipt"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"ballot_id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"ballot_tracker_url"}},"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":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"uuid"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"tenant_id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"uuid"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"election_id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"uuid"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"create_ballot_receipt"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"ballot_id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"ballot_id"}}},{"kind":"Argument","name":{"kind":"Name","value":"ballot_tracker_url"},"value":{"kind":"Variable","name":{"kind":"Name","value":"ballot_tracker_url"}}},{"kind":"Argument","name":{"kind":"Name","value":"election_event_id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"election_event_id"}}},{"kind":"Argument","name":{"kind":"Name","value":"tenant_id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"tenant_id"}}},{"kind":"Argument","name":{"kind":"Name","value":"election_id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"election_id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"ballot_id"}},{"kind":"Field","name":{"kind":"Name","value":"status"}}]}}]}}]} as unknown as DocumentNode; export const FetchDocumentDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"FetchDocument"},"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":"fetchDocument"},"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":"url"}}]}}]}}]} as unknown as DocumentNode; @@ -20690,4 +20733,5 @@ export const GetDocumentDocument = {"kind":"Document","definitions":[{"kind":"Op export const GetElectionEventDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetElectionEvent"},"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":"tenantId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"uuid"}}}}],"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":"_and"},"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":"id"}},{"kind":"Field","name":{"kind":"Name","value":"presentation"}},{"kind":"Field","name":{"kind":"Name","value":"status"}}]}}]}}]} as unknown as DocumentNode; export const GetElectionsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetElections"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"electionIds"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","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":"id"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"_in"},"value":{"kind":"Variable","name":{"kind":"Name","value":"electionIds"}}}]}}]}}],"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":"name"}},{"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":"alias"}}]}}]}}]} as unknown as DocumentNode; export const GetSupportMaterialsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetSupportMaterials"},"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":"tenantId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"uuid"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"sequent_backend_support_material"},"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":"is_hidden"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"_eq"},"value":{"kind":"BooleanValue","value":false}}]}},{"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":"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":"data"}},{"kind":"Field","name":{"kind":"Name","value":"document_id"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"annotations"}},{"kind":"Field","name":{"kind":"Name","value":"created_at"}},{"kind":"Field","name":{"kind":"Name","value":"election_event_id"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}},{"kind":"Field","name":{"kind":"Name","value":"labels"}},{"kind":"Field","name":{"kind":"Name","value":"last_updated_at"}},{"kind":"Field","name":{"kind":"Name","value":"tenant_id"}}]}}]}}]} as unknown as DocumentNode; -export const InsertCastVoteDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"InsertCastVote"},"variableDefinitions":[{"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":"ballotId"}},"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":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"insert_cast_vote"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"election_id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"electionId"}}},{"kind":"Argument","name":{"kind":"Name","value":"ballot_id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"ballotId"}}},{"kind":"Argument","name":{"kind":"Name","value":"content"},"value":{"kind":"Variable","name":{"kind":"Name","value":"content"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"ballot_id"}},{"kind":"Field","name":{"kind":"Name","value":"election_id"}},{"kind":"Field","name":{"kind":"Name","value":"election_event_id"}},{"kind":"Field","name":{"kind":"Name","value":"tenant_id"}},{"kind":"Field","name":{"kind":"Name","value":"election_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":"content"}},{"kind":"Field","name":{"kind":"Name","value":"cast_ballot_signature"}},{"kind":"Field","name":{"kind":"Name","value":"voter_id_string"}},{"kind":"Field","name":{"kind":"Name","value":"election_event_id"}}]}}]}}]} as unknown as DocumentNode; \ No newline at end of file +export const InsertCastVoteDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"InsertCastVote"},"variableDefinitions":[{"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":"ballotId"}},"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":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"insert_cast_vote"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"election_id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"electionId"}}},{"kind":"Argument","name":{"kind":"Name","value":"ballot_id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"ballotId"}}},{"kind":"Argument","name":{"kind":"Name","value":"content"},"value":{"kind":"Variable","name":{"kind":"Name","value":"content"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"ballot_id"}},{"kind":"Field","name":{"kind":"Name","value":"election_id"}},{"kind":"Field","name":{"kind":"Name","value":"election_event_id"}},{"kind":"Field","name":{"kind":"Name","value":"tenant_id"}},{"kind":"Field","name":{"kind":"Name","value":"election_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":"content"}},{"kind":"Field","name":{"kind":"Name","value":"cast_ballot_signature"}},{"kind":"Field","name":{"kind":"Name","value":"voter_id_string"}},{"kind":"Field","name":{"kind":"Name","value":"election_event_id"}}]}}]}}]} as unknown as DocumentNode; +export const ListCastVoteMessagesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"listCastVoteMessages"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"tenantId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"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":"electionId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"ballotId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"limit"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"offset"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"orderBy"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ElectoralLogOrderBy"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"list_cast_vote_messages"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"tenant_id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"tenantId"}}},{"kind":"Argument","name":{"kind":"Name","value":"election_event_id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"electionEventId"}}},{"kind":"Argument","name":{"kind":"Name","value":"election_id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"electionId"}}},{"kind":"Argument","name":{"kind":"Name","value":"ballot_id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"ballotId"}}},{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"Variable","name":{"kind":"Name","value":"limit"}}},{"kind":"Argument","name":{"kind":"Name","value":"offset"},"value":{"kind":"Variable","name":{"kind":"Name","value":"offset"}}},{"kind":"Argument","name":{"kind":"Name","value":"order_by"},"value":{"kind":"Variable","name":{"kind":"Name","value":"orderBy"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"list"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"statement_timestamp"}},{"kind":"Field","name":{"kind":"Name","value":"statement_kind"}},{"kind":"Field","name":{"kind":"Name","value":"ballot_id"}},{"kind":"Field","name":{"kind":"Name","value":"username"}}]}},{"kind":"Field","name":{"kind":"Name","value":"total"}}]}}]}}]} as unknown as DocumentNode; \ No newline at end of file diff --git a/packages/voting-portal/src/queries/listCastVoteMessages.ts b/packages/voting-portal/src/queries/listCastVoteMessages.ts new file mode 100644 index 00000000000..972eecd3322 --- /dev/null +++ b/packages/voting-portal/src/queries/listCastVoteMessages.ts @@ -0,0 +1,35 @@ +// SPDX-FileCopyrightText: 2024 Kevin Nguyen +// +// SPDX-License-Identifier: AGPL-3.0-only + +import {gql} from "@apollo/client" + +export const LIST_CAST_VOTE_MESSAGES = gql` + query listCastVoteMessages( + $tenantId: String! + $electionEventId: String! + $electionId: String + $ballotId: String! + $limit: Int + $offset: Int + $orderBy: ElectoralLogOrderBy + ) { + list_cast_vote_messages( + tenant_id: $tenantId + election_event_id: $electionEventId + election_id: $electionId + ballot_id: $ballotId + limit: $limit + offset: $offset + order_by: $orderBy + ) { + list { + statement_timestamp + statement_kind + ballot_id + username + } + total + } + } +` diff --git a/packages/voting-portal/src/routes/BallotLocator.tsx b/packages/voting-portal/src/routes/BallotLocator.tsx index 90defe2df91..717e1daa795 100644 --- a/packages/voting-portal/src/routes/BallotLocator.tsx +++ b/packages/voting-portal/src/routes/BallotLocator.tsx @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: AGPL-3.0-only -import React, {useContext, useEffect, useState} from "react" +import React, {useContext, useEffect, useState, useRef} from "react" import {useTranslation} from "react-i18next" import { BreadCrumbSteps, @@ -13,15 +13,23 @@ import { IconButton, Dialog, } from "@sequentech/ui-essentials" -import {stringToHtml} from "@sequentech/ui-core" +import {stringToHtml, EShowCastVoteLogsPolicy} from "@sequentech/ui-core" import {Box, TextField, Typography, Button, Stack} from "@mui/material" import {styled} from "@mui/material/styles" +import Tabs from "@mui/material/Tabs" +import Tab from "@mui/material/Tab" import {Link, useLocation, useNavigate, useParams} from "react-router-dom" import {GET_CAST_VOTE} from "../queries/GetCastVote" -import {useQuery} from "@apollo/client" -import {GetBallotStylesQuery, GetCastVoteQuery, GetElectionEventQuery} from "../gql/graphql" +import {useQuery, useMutation} from "@apollo/client" +import { + GetBallotStylesQuery, + GetCastVoteQuery, + GetElectionEventQuery, + ListCastVoteMessagesQuery, +} from "../gql/graphql" import {faAngleLeft, faCircleQuestion} from "@fortawesome/free-solid-svg-icons" import {GET_BALLOT_STYLES} from "../queries/GetBallotStyles" +import {LIST_CAST_VOTE_MESSAGES} from "../queries/listCastVoteMessages" import {updateBallotStyleAndSelection} from "../services/BallotStyles" import {useAppDispatch, useAppSelector} from "../store/hooks" import {selectFirstBallotStyle} from "../store/ballotStyles/ballotStylesSlice" @@ -30,6 +38,16 @@ import {SettingsContext} from "../providers/SettingsContextProvider" import useUpdateTranslation from "../hooks/useUpdateTranslation" import {GET_ELECTION_EVENT} from "../queries/GetElectionEvent" import {IElectionEvent} from "../store/electionEvents/electionEventsSlice" +import Table from "@mui/material/Table" +import TableSortLabel from "@mui/material/TableSortLabel" +import TableBody from "@mui/material/TableBody" +import TableCell from "@mui/material/TableCell" +import TableContainer from "@mui/material/TableContainer" +import TablePagination from "@mui/material/TablePagination" +import TableHead from "@mui/material/TableHead" +import TableRow from "@mui/material/TableRow" +import Paper from "@mui/material/Paper" +import {ICastVoteEntry} from "../types/castVoteLogEntry" const StyledLink = styled(Link)` text-decoration: none; @@ -85,7 +103,9 @@ function isHex(str: string) { if (str.trim() === "") { return true } - + if (str.length % 2 !== 0) { + return false + } const regex = /^[0-9a-fA-F]+$/ return regex.test(str) } @@ -96,7 +116,363 @@ const StyledApp = styled(Stack)<{css: string}>` ${({css}) => css} ` +interface TabPanelProps { + children?: React.ReactNode + index: number + value: number +} + +const CustomTabPanel: React.FC = ({children, index, value}) => { + return ( + + ) +} + const BallotLocator: React.FC = () => { + const {t} = useTranslation() + const location = useLocation() + const {tenantId, eventId, electionId} = useParams() + const allowSendRequest = useRef(true) + const [value, setValue] = React.useState(0) + const [inputBallotId, setInputBallotId] = useState("") + const [rows, setRows] = useState([]) + const [total, setTotal] = useState(0) + const [ballotIdNotFoundErr, setBallotIdNotFoundErr] = useState(false) + const [somethingWentWrongErr, setSomethingWentWrongErr] = useState(false) + const validatedBallotId = isHex(inputBallotId ?? "") + const [showCVLogsPolicy, setShowCVLogsPolicy] = useState(false) + const {globalSettings} = useContext(SettingsContext) + const [page, setPage] = React.useState(0) + const [rowsPerPage, setRowsPerPage] = React.useState(5) + const lastCVRequestTimestamp = useRef(undefined) // Timestamp of last LIST_CAST_VOTE_MESSAGES request + const {data: dataElectionEvent} = useQuery(GET_ELECTION_EVENT, { + variables: { + electionEventId: eventId, + tenantId, + }, + skip: globalSettings.DISABLE_AUTH, // Skip query if in demo mode + }) + + const {refetch} = useQuery(LIST_CAST_VOTE_MESSAGES, { + variables: { + tenantId, + electionEventId: eventId, + electionId, + ballotId: inputBallotId, + }, + skip: true, + }) + + useUpdateTranslation({ + electionEvent: dataElectionEvent?.sequent_backend_election_event[0] as IElectionEvent, + }) // Overwrite translations + const customCss = dataElectionEvent?.sequent_backend_election_event[0]?.presentation?.css + let fetchTimeout: any = useRef() + + const requestCVMsgs = async (headerName?: string, newOrder?: string) => { + let duration = lastCVRequestTimestamp.current + ? Date.now() - lastCVRequestTimestamp.current + : undefined + let tooQuick = duration ? duration < 500 : false + lastCVRequestTimestamp.current = Date.now() + async function tryFetchMessages() { + try { + let limit = rowsPerPage + let offset = page * rowsPerPage + const {data} = await refetch({ + ballotId: inputBallotId, + orderBy: {[headerName ?? "id"]: newOrder ?? "desc"}, + limit, + offset, + }) + console.log(data) + if (data?.list_cast_vote_messages) { + setRows((data?.list_cast_vote_messages?.list ?? []) as ICastVoteEntry[]) + setTotal(data?.list_cast_vote_messages?.total) + setBallotIdNotFoundErr( + inputBallotId.length > 0 && data?.list_cast_vote_messages?.list.length === 0 + ) + } + } catch (e) { + setSomethingWentWrongErr(true) + } + } + + if (tooQuick) { + // Start interval + // if timeout is already running, destroy it and create a new one. + clearTimeout(fetchTimeout.current) + fetchTimeout.current = setTimeout(async () => { + await tryFetchMessages() + }, 1000) + } + + if (globalSettings.DISABLE_AUTH || tooQuick || !validatedBallotId) { + return + } + await tryFetchMessages() + } + + const onClickHeader = (headerName: string, newOrder: string) => { + requestCVMsgs(headerName, newOrder) + } + useEffect(() => { + if (validatedBallotId) { + setBallotIdNotFoundErr(false) + } + const showLogs = dataElectionEvent?.sequent_backend_election_event[0]?.presentation + ?.show_cast_vote_logs as EShowCastVoteLogsPolicy + setShowCVLogsPolicy(showLogs === EShowCastVoteLogsPolicy.SHOW_LOGS_TAB) + // the length must be an even number of characters + if (showLogs && allowSendRequest.current) { + allowSendRequest.current = false + requestCVMsgs() + } + }, [inputBallotId, dataElectionEvent, page, rowsPerPage]) + + const handleChangePage = (event: unknown, newPage: number) => { + allowSendRequest.current = true + setPage(newPage) + } + const handleChangeRowsPerPage = (event: React.ChangeEvent) => { + allowSendRequest.current = true + setRowsPerPage(parseInt(event.target.value, 10)) + setPage(0) + } + + const a11yProps = (index: number) => { + return { + "id": `simple-tab-${index}`, + "aria-controls": `simple-tabpanel-${index}`, + } + } + const handleChange = (event: React.SyntheticEvent, newValue: number) => { + setValue(newValue) + } + + const captureEnter: React.KeyboardEventHandler = (event) => { + allowSendRequest.current = true + } + + return ( + + + + + {showCVLogsPolicy && ( + + )} + + + + + + + + + + + + + + + + + + ) +} + +interface LogsTableProps { + rows: ICastVoteEntry[] + total: number + onOrderBy?: (headerName: string, newOrder: string) => void + rowsPerPage: number + handleChangeRowsPerPage: (event: React.ChangeEvent) => void + page: number + handleChangePage: (event: unknown, newValue: number) => void + somethingWentWrongErr: boolean +} + +const LogsTable: React.FC = ({ + rows, + total, + onOrderBy, + rowsPerPage, + handleChangeRowsPerPage, + page, + handleChangePage, + somethingWentWrongErr = false, +}) => { + const {t} = useTranslation() + const [orderBy, setOrderBy] = useState("") + const [order, setOrder] = useState<"desc" | "asc" | undefined>("desc") + + const onClickHeader = (headerName: string) => { + setOrderBy(headerName) + const newOrder = order === "desc" ? "asc" : "desc" + setOrder(newOrder) + onOrderBy?.(headerName, newOrder) + } + + return ( + <> + {t("ballotLocator.totalBallots", {total})} + + + + + + onClickHeader("username")} + > + {t("ballotLocator.column.username")} + + + + onClickHeader("ballot_id")} + > + {t("ballotLocator.column.ballot_id")} + + + + onClickHeader("statement_kind")} + > + {t("ballotLocator.column.statement_kind")} + + + + onClickHeader("statement_timestamp")} + > + {t("ballotLocator.column.statement_timestamp")} + + + + + + {rows.map((row, index) => ( + + {row.username ?? "****"} + {row.ballot_id} + {row.statement_kind} + + {new Date(row.statement_timestamp * 1000).toUTCString()} + + + ))} + +
+
+ + {somethingWentWrongErr && {t("errors.page.somethingWrong")}} + + ) +} + +interface BallotIdInputProps { + inputBallotId: string + setInputBallotId: (value: string) => void + validatedBallotId: boolean + ballotIdNotFoundErr?: boolean + captureEnter: React.KeyboardEventHandler + placeholderLabel: string +} + +const BallotIdInput: React.FC = ({ + inputBallotId, + setInputBallotId, + validatedBallotId, + ballotIdNotFoundErr = false, + captureEnter, + placeholderLabel, +}) => { + const {t} = useTranslation() + + return ( + <> + ) => { + setInputBallotId(event.target.value) + }} + value={inputBallotId} + InputLabelProps={{ + shrink: true, + }} + label="Ballot ID" + placeholder={t(placeholderLabel)} + onKeyDown={captureEnter} + /> + {!validatedBallotId && ( + {t("ballotLocator.wrongFormatBallotId")} + )} + {ballotIdNotFoundErr && validatedBallotId && ( + {t("ballotLocator.ballotIdNotFoundAtFilter")} + )} + + ) +} + +interface BallotLocatorLogicProps { + customCss: any +} + +const BallotLocatorLogic: React.FC = ({customCss}) => { const {tenantId, eventId, electionId, ballotId} = useParams() const [openTitleHelp, setOpenTitleHelp] = useState(false) const navigate = useNavigate() @@ -104,7 +480,6 @@ const BallotLocator: React.FC = () => { const {t} = useTranslation() const [inputBallotId, setInputBallotId] = useState("") const {globalSettings} = useContext(SettingsContext) - const hasBallotId = !!ballotId const {data: dataBallotStyles} = useQuery(GET_BALLOT_STYLES) @@ -119,21 +494,9 @@ const BallotLocator: React.FC = () => { electionId, ballotId, }, - skip: globalSettings.DISABLE_AUTH, // Skip query if in demo mode - }) - - const {data: dataElectionEvent} = useQuery(GET_ELECTION_EVENT, { - variables: { - electionEventId: eventId, - tenantId, - }, - skip: globalSettings.DISABLE_AUTH, // Skip query if in demo mode + skip: globalSettings.DISABLE_AUTH || !hasBallotId, // Skip query if in demo mode }) - useUpdateTranslation({ - electionEvent: dataElectionEvent?.sequent_backend_election_event[0] as IElectionEvent, - }) // Overwrite translations - useEffect(() => { if (dataBallotStyles && dataBallotStyles.sequent_backend_ballot_style.length > 0) { updateBallotStyleAndSelection(dataBallotStyles, dispatch) @@ -162,15 +525,15 @@ const BallotLocator: React.FC = () => { } } + const ConditionalStyledApp = customCss ? StyledApp : Stack + return ( - - + + @@ -217,16 +580,6 @@ const BallotLocator: React.FC = () => { {t("ballotLocator.description")} - - - - - {hasBallotId && !loading && ( @@ -239,25 +592,14 @@ const BallotLocator: React.FC = () => { )} {!hasBallotId && ( - <> - ) => { - setInputBallotId(event.target.value) - }} - value={inputBallotId} - InputLabelProps={{ - shrink: true, - }} - label="Ballot ID" - placeholder={t("ballotLocator.description")} - onKeyDown={captureEnter} - /> - {!validatedBallotId && ( - {t("ballotLocator.wrongFormatBallotId")} - )} - + )} - {hasBallotId && ballotContent && ( <> {t("ballotLocator.contentDesc")} @@ -286,7 +628,7 @@ const BallotLocator: React.FC = () => { )} - + ) } diff --git a/packages/voting-portal/src/translations/cat.ts b/packages/voting-portal/src/translations/cat.ts index ea3f1c4aadb..ecc8712d74c 100644 --- a/packages/voting-portal/src/translations/cat.ts +++ b/packages/voting-portal/src/translations/cat.ts @@ -311,6 +311,10 @@ const catalanTranslation: TranslationType = { notFound: "El teu ID de Papereta {{ballotId}} no ha estat localitzat", contentDesc: "Aquest és el contingut de la teva Papereta: ", wrongFormatBallotId: "Format incorrecte per l'ID de la Papereta", + ballotIdNotFoundAtFilter: + "No trobat, comprova que l'ID de la Papereta estigui correcte i pertanyi a l'usuari actual.", + filterByBallotId: "Filtra per ID de la Papereta", + totalBallots: "Paperetes: {{total}}", steps: { lookup: "Localitza la teva Papereta", result: "Resultat", @@ -321,6 +325,16 @@ const catalanTranslation: TranslationType = { "Aquesta pantalla permet al votant trobar la seva Papereta utilitzant l'ID de la Papereta per recuperar-la. Aquest procediment permet comprovar que el seu vot va ser emès correctament i que el vot registrat coincideix amb el vot xifrat que va emetre.", ok: "D'acord", }, + tabs: { + logs: "Logs", + ballotLocator: "Localitzador de la Papereta", + }, + column: { + statement_kind: "Tipus", + statement_timestamp: "Marca de temps", + username: "Usuari", + ballot_id: "ID de la Papereta", + }, }, }, } diff --git a/packages/voting-portal/src/translations/en.ts b/packages/voting-portal/src/translations/en.ts index f8a7edc4704..b66bfb74368 100644 --- a/packages/voting-portal/src/translations/en.ts +++ b/packages/voting-portal/src/translations/en.ts @@ -1,5 +1,8 @@ // SPDX-FileCopyrightText: 2022 Félix Robles // + +import BallotLocator from "../routes/BallotLocator" + // SPDX-License-Identifier: AGPL-3.0-only const englishTranslation = { translations: { @@ -305,6 +308,10 @@ const englishTranslation = { notFound: "Your ballot ID {{ballotId}} has not been located", contentDesc: "This is your Ballot content: ", wrongFormatBallotId: "Wrong format for Ballot ID", + ballotIdNotFoundAtFilter: + "Not found, check that your Ballot ID is correct and belongs to this user.", + filterByBallotId: "Filter by Ballot ID", + totalBallots: "Total Ballots: {{total}}", steps: { lookup: "Locate your Ballot", result: "Result", @@ -315,6 +322,16 @@ const englishTranslation = { "This screen allows the voter to find their vote by using the Ballot ID to retrieve it. This procedure enables checking that their ballot was correctly cast and that the recorded ballot coincides with the encrypted ballot they sent.", ok: "OK", }, + tabs: { + logs: "Logs", + ballotLocator: "Ballot Locator", + }, + column: { + statement_kind: "Statement kind", + statement_timestamp: "Statement Timestamp", + username: "Username", + ballot_id: "Ballot ID", + }, }, }, } diff --git a/packages/voting-portal/src/translations/es.ts b/packages/voting-portal/src/translations/es.ts index 3a1571cbd60..90578ecceb3 100644 --- a/packages/voting-portal/src/translations/es.ts +++ b/packages/voting-portal/src/translations/es.ts @@ -312,6 +312,10 @@ const spanishTranslation: TranslationType = { notFound: "Tu ID de Papeleta {{ballotId}} no ha sido localizada", contentDesc: "Este es el contenido de tu Papeleta: ", wrongFormatBallotId: "Formato incorrecto para el ID de la Papeleta", + ballotIdNotFoundAtFilter: + "No encontrado, compruebe que el ID de la Papeleta sea correcto y pertenezca a este usuario.", + filterByBallotId: "Filtrar por ID de Papeleta", + totalBallots: "Papeletas: {{total}}", steps: { lookup: "Localiza tu Papeleta", result: "Resultado", @@ -322,6 +326,16 @@ const spanishTranslation: TranslationType = { "Esta pantalla le permite al votante encontrar su Papeleta utilizando el ID de la Papeleta para recuperarlo. Este procedimiento permite comprobar que su voto fue emitido correctamente y que el voto registrado coincide con el voto cifrado que emitió.", ok: "OK", }, + tabs: { + logs: "Logs", + ballotLocator: "Localizador de Papeletas", + }, + column: { + statement_kind: "Tipo", + statement_timestamp: "Marca de tiempo", + username: "Usuario", + ballot_id: "ID de Papeleta", + }, }, }, } diff --git a/packages/voting-portal/src/translations/eu.ts b/packages/voting-portal/src/translations/eu.ts index 9b85021c710..004cb2d4f27 100644 --- a/packages/voting-portal/src/translations/eu.ts +++ b/packages/voting-portal/src/translations/eu.ts @@ -312,6 +312,9 @@ const basqueTranslation: TranslationType = { notFound: "Zure bozketa IDa {{ballotId}} ez da lokalizatu", contentDesc: "Hau da zure Bozketa edukia: ", wrongFormatBallotId: "Bozketa IDaren formatu okerra", + ballotIdNotFoundAtFilter: "Zure bozketa IDa ez da {{ballotId}} bozketa zerrendan", + filterByBallotId: "Filtratu Bozketa IDa", + totalBallots: "Bozketa kopurua: {{total}}", steps: { lookup: "Lokalizatu zure Bozketa", result: "Emaitza", @@ -322,6 +325,16 @@ const basqueTranslation: TranslationType = { "Pantaila honek bozkatzaileari bere botoa aurkitzeko aukera ematen dio Bozketa IDa erabiliz berreskuratzeko. Prozedura honek beren bozketa zuzen eman dela eta erregistratutako bozketa bidali zuten zifratutako bozketarekin bat datorrela egiaztatzeko aukera ematen du.", ok: "Ados", }, + tabs: { + logs: "Logs", + ballotLocator: "Bozketa Lokalizatzaile", + }, + column: { + statement_kind: "Adierazpen mota", + statement_timestamp: "Adierazpen denbora-marka", + username: "Erabiltzaile izena", + ballot_id: "Bozketa IDa", + }, }, }, } diff --git a/packages/voting-portal/src/translations/fr.ts b/packages/voting-portal/src/translations/fr.ts index 2f322cd5a52..dca3b62e2f7 100644 --- a/packages/voting-portal/src/translations/fr.ts +++ b/packages/voting-portal/src/translations/fr.ts @@ -311,6 +311,10 @@ const frenchTranslation: TranslationType = { notFound: "Votre ID de Bulletin {{ballotId}} n'a pas été localisé", contentDesc: "Voici le contenu de votre Bulletin : ", wrongFormatBallotId: "Format incorrect pour l'ID du Bulletin", + ballotIdNotFoundAtFilter: + "Non trouvé, veuillez verifier que l'ID du Bulletin soit correct et appartenir a cet utilisateur.", + filterByBallotId: "Filtrez par ID de Bulletin", + totalBallots: "Total: {{total}}", steps: { lookup: "Localisez votre Bulletin", result: "Résultat", @@ -321,6 +325,16 @@ const frenchTranslation: TranslationType = { "Cet écran permet au votant de trouver son bulletin en utilisant l'ID du Bulletin pour le récupérer. Cette procédure permet de vérifier que son vote a été émis correctement et que le vote enregistré correspond au vote chiffré émis.", ok: "OK", }, + tabs: { + logs: "Logs", + ballotLocator: "Localisez votre Bulletin", + }, + column: { + statement_kind: "Type", + statement_timestamp: "Marque de temps", + username: "Nom d'utilisateur", + ballot_id: "ID de Bulletin", + }, }, }, } diff --git a/packages/voting-portal/src/translations/gl.ts b/packages/voting-portal/src/translations/gl.ts index c7143729674..a8b4af7e847 100644 --- a/packages/voting-portal/src/translations/gl.ts +++ b/packages/voting-portal/src/translations/gl.ts @@ -310,6 +310,10 @@ const galegoTranslation: TranslationType = { notFound: "O teu ID de papeleta {{ballotId}} non foi localizado", contentDesc: "Este é o contido da túa Papeleta:", wrongFormatBallotId: "Formato incorrecto para o ID da Papeleta", + ballotIdNotFoundAtFilter: + "Non atopado, comprobe que o ID da Papeleta seja correcto e pertenezca a este usuario.", + filterByBallotId: "Filtrar por ID da Papeleta", + totalBallots: "Papeletas: {{total}}", steps: { lookup: "Localiza a túa Papeleta", result: "Resultado", @@ -320,6 +324,16 @@ const galegoTranslation: TranslationType = { "Esta pantalla permite ao votante atopar o seu voto utilizando o ID da papeleta para recuperalo. Este procedemento permite verificar que a súa papeleta foi emitida correctamente e que a papeleta rexistrada coincide coa papeleta encriptada enviada.", ok: "Aceptar", }, + tabs: { + logs: "Logs", + ballotLocator: "Localiza a tua Papeleta", + }, + column: { + statement_kind: "Tipo", + statement_timestamp: "Marca de tempo", + username: "Nome de usuario", + ballot_id: "ID da papeleta", + }, }, }, } diff --git a/packages/voting-portal/src/translations/nl.ts b/packages/voting-portal/src/translations/nl.ts index 43ce75586f5..50696796000 100644 --- a/packages/voting-portal/src/translations/nl.ts +++ b/packages/voting-portal/src/translations/nl.ts @@ -311,6 +311,10 @@ const dutchTranslation: TranslationType = { notFound: "Uw stembiljet ID {{ballotId}} is niet gelokaliseerd", contentDesc: "Dit is de inhoud van uw stembiljet: ", wrongFormatBallotId: "Verkeerd formaat voor Stembiljet ID", + ballotIdNotFoundAtFilter: + "Niet gevonden, controleer dat uw Stembiljet ID correct is en behoort tot deze gebruiker.", + filterByBallotId: "Filteren op Stembiljet ID", + totalBallots: "Aantal stembiljet: {{total}}", steps: { lookup: "Lokaliseer uw Stembiljet", result: "Resultaat", @@ -321,6 +325,16 @@ const dutchTranslation: TranslationType = { "Dit scherm stelt de kiezer in staat om zijn/haar stem te vinden door de Stembiljet ID te gebruiken om deze op te halen. Deze procedure maakt het mogelijk te controleren of hun stembiljet correct is uitgebracht en of het geregistreerde stembiljet overeenkomt met het versleutelde stembiljet dat ze hebben verzonden.", ok: "OK", }, + tabs: { + logs: "Logs", + ballotLocator: "Lokaliseer uw Stembiljet", + }, + column: { + statement_kind: "Type", + statement_timestamp: "Tijdstip", + username: "Gebruikersnaam", + ballot_id: "Stembiljet ID", + }, }, }, } diff --git a/packages/voting-portal/src/translations/tl.ts b/packages/voting-portal/src/translations/tl.ts index deceed751a8..26dca5920cc 100644 --- a/packages/voting-portal/src/translations/tl.ts +++ b/packages/voting-portal/src/translations/tl.ts @@ -311,6 +311,9 @@ const tagalogTranslation: TranslationType = { notFound: "Ang iyong ballot ID {{ballotId}} ay hindi natagpuan", contentDesc: "Ito ang nilalaman ng iyong balota: ", wrongFormatBallotId: "Mali ang format para sa Ballot ID", + ballotIdNotFoundAtFilter: "Hindi natagpuan ang iyong ballot ID sa filter", + filterByBallotId: "Tumutugma sa Ballot ID", + totalBallots: "Kumulang mga balota: {{total}}", steps: { lookup: "Hanapin ang Iyong Balota", result: "Resulta", @@ -321,6 +324,16 @@ const tagalogTranslation: TranslationType = { "Sa screen na ito maaring hanapin ng botante ang kaniyang boto gamit ang Ballot ID upang matagpuan ito. Sa ganitong pamamaraan, maaring suriin kung ang balota ay nai-submit nang tama at kung ang naitalang balota ay tumutugma sa encrypted na balota na kanilang ipinadala.", ok: "OK", }, + tabs: { + logs: "Logs", + ballotLocator: "Lokaliseer uw Stembiljet", + }, + column: { + statement_kind: "Type", + statement_timestamp: "Tijdstip", + username: "Gebruikersnaam", + ballot_id: "Stembiljet ID", + }, }, }, } diff --git a/packages/voting-portal/src/types/castVoteLogEntry.ts b/packages/voting-portal/src/types/castVoteLogEntry.ts new file mode 100644 index 00000000000..5fb76dde6f3 --- /dev/null +++ b/packages/voting-portal/src/types/castVoteLogEntry.ts @@ -0,0 +1,10 @@ +// SPDX-FileCopyrightText: 2024 Sequent Tech +// +// SPDX-License-Identifier: AGPL-3.0-only + +export interface ICastVoteEntry { + statement_timestamp: number + statement_kind: string + ballot_id: string + username: string +} diff --git a/packages/windmill/external-bin/generate_logs.rs b/packages/windmill/external-bin/generate_logs.rs old mode 100644 new mode 100755 index 50e1e8ff1ea..949a6325ac2 --- a/packages/windmill/external-bin/generate_logs.rs +++ b/packages/windmill/external-bin/generate_logs.rs @@ -8,6 +8,7 @@ use base64::Engine; use chrono::{TimeZone, Utc}; use clap::Parser; use csv::Writer; +use electoral_log::client::types::ElectoralLogMessage; use electoral_log::messages::message::Message; use immudb_rs::{sql_value::Value as ImmudbSqlValue, Client}; use serde::Deserialize; @@ -187,10 +188,18 @@ async fn main() -> Result<()> { info!(total_rows_fetched, "Processed rows from stream..."); } - let elog_row = match ElectoralLogRow::try_from(individual_row) { + let elog_msg = match ElectoralLogMessage::try_from(individual_row) { + Ok(elog_msg) => elog_msg, + Err(e) => { + warn!(error = %e, "Failed to parse ImmudbRow into ElectoralLogMessage from stream batch."); + continue; + } + }; + + let elog_row = match ElectoralLogRow::try_from(elog_msg.clone()) { Ok(elog_row) => elog_row, Err(e) => { - warn!(error = %e, "Failed to parse ImmudbRow into ElectoralLogRow from stream batch."); + warn!(error = %e, "Failed to parse ImmudbRow into ElectoralLogRow from ElectoralLogMessage."); continue; } }; @@ -217,10 +226,10 @@ async fn main() -> Result<()> { }; let extracted_election_id_opt = message.election_id.clone(); - let activity_log_row = match ActivityLogRow::try_from(elog_row.clone()) { + let activity_log_row = match ActivityLogRow::try_from(elog_msg.clone()) { Ok(activity_log_row) => activity_log_row, Err(e) => { - warn!(log_id = elog_row.id, error = %e, "Failed to transform ElectoralLogRow."); + warn!(log_id = elog_msg.id, error = %e, "Failed to transform ElectoralLogMessage."); continue; } }; diff --git a/packages/windmill/external-bin/janitor/templates/electionEvent.hbs b/packages/windmill/external-bin/janitor/templates/electionEvent.hbs index bba0196d570..c0b29eb22e7 100644 --- a/packages/windmill/external-bin/janitor/templates/electionEvent.hbs +++ b/packages/windmill/external-bin/janitor/templates/electionEvent.hbs @@ -91,6 +91,7 @@ }, "elections_order": "alphabetical", "show_user_profile": true, + "show_cast_vote_logs": "hide-logs-tab", "skip_election_list": false, "voting_portal_countdown_policy": { "policy": "COUNTDOWN_WITH_ALERT", diff --git a/packages/windmill/src/services/ballot_styles/ballot_publication.rs b/packages/windmill/src/services/ballot_styles/ballot_publication.rs index 9677bdb0fe2..fa43951f353 100644 --- a/packages/windmill/src/services/ballot_styles/ballot_publication.rs +++ b/packages/windmill/src/services/ballot_styles/ballot_publication.rs @@ -200,7 +200,7 @@ pub async fn update_publish_ballot( Some(username), ) .await - .with_context(|| "error posting to the electoral log")?; + .map_err(|e| anyhow!("error posting to the electoral log: {e}"))?; Ok(()) } diff --git a/packages/windmill/src/services/electoral_log.rs b/packages/windmill/src/services/electoral_log.rs index a067c20efff..8544775f659 100644 --- a/packages/windmill/src/services/electoral_log.rs +++ b/packages/windmill/src/services/electoral_log.rs @@ -12,37 +12,39 @@ use crate::services::vault; use crate::tasks::electoral_log::{ enqueue_electoral_log_event, LogEventInput, INTERNAL_MESSAGE_TYPE, }; -use crate::types::resources::{Aggregate, DataList, OrderDirection, TotalAggregate}; -use anyhow::{anyhow, Context, Result}; -use b3::messages::message::{self, Signer as _}; +use crate::types::resources::{Aggregate, DataList, TotalAggregate}; +use anyhow::{anyhow, ensure, Context, Result}; +use b3::messages::message::Signer; use base64::engine::general_purpose; use base64::Engine; use deadpool_postgres::Transaction; -use electoral_log::assign_value; -use electoral_log::messages::message::Message; -use electoral_log::messages::message::SigningData; -use electoral_log::messages::newtypes::ErrorMessageString; -use electoral_log::messages::newtypes::KeycloakEventTypeString; +use electoral_log::client::types::*; +use electoral_log::messages::message::{Message, SigningData}; use electoral_log::messages::newtypes::*; -use electoral_log::messages::statement::StatementHead; -use electoral_log::ElectoralLogMessage; -use immudb_rs::{sql_value::Value, Client, NamedParam, Row, SqlValue, TxMode}; -use sequent_core::ballot::VotingStatusChannel; -use sequent_core::serialization::base64::{Base64Deserialize, Base64Serialize}; +use electoral_log::messages::statement::{StatementBody, StatementType}; +use immudb_rs::{sql_value::Value, Client, NamedParam, Row, TxMode}; +use rust_decimal::prelude::ToPrimitive; use sequent_core::serialization::deserialize_with_path; -use sequent_core::services::date::ISO8601; use sequent_core::util::retry::retry_with_exponential_backoff; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; use std::time::Duration; use strand::backend::ristretto::RistrettoCtx; use strand::hash::HashWrapper; +use strand::hash::STRAND_HASH_LENGTH_BYTES; use strand::serialization::StrandDeserialize; use strand::signature::StrandSignatureSk; -use strum_macros::{Display, EnumString, ToString}; use tempfile::NamedTempFile; -use tokio_stream::{Stream, StreamExt}; -use tracing::{event, info, instrument, Level}; +use tokio_stream::StreamExt; +use tracing::{info, instrument, warn}; + +pub const IMMUDB_ROWS_LIMIT: usize = 2500; +pub const MAX_ROWS_PER_PAGE: usize = 50; + +/// Ballot_id input is the first half of the original hash which is stored in the electoral log. +pub const BALLOT_ID_LENGTH_BYTES: usize = STRAND_HASH_LENGTH_BYTES / 2; +/// Ballot_id input is in HEX, each byte is represented in 2 chars. +pub const BALLOT_ID_LENGTH_CHARS: usize = BALLOT_ID_LENGTH_BYTES * 2; + pub struct ElectoralLog { pub(crate) sd: SigningData, pub(crate) elog_database: String, @@ -737,188 +739,14 @@ impl ElectoralLog { } } -// Enumeration for the valid fields in the immudb table -#[derive(Debug, Deserialize, Hash, PartialEq, Eq, EnumString, Display)] -#[serde(rename_all = "snake_case")] -#[strum(serialize_all = "snake_case")] -pub enum OrderField { - Id, - Created, - StatementTimestamp, - StatementKind, - Message, - UserId, - Username, - SenderPk, - LogType, - EventType, - Description, - Version, -} - -#[derive(Deserialize, Debug)] -pub struct GetElectoralLogBody { - pub tenant_id: String, - pub election_event_id: String, - pub limit: Option, - pub offset: Option, - pub filter: Option>, - pub order_by: Option>, - pub election_id: Option, - pub area_ids: Option>, - pub only_with_user: Option, -} - -impl GetElectoralLogBody { - // Returns the SQL clauses related to the request along with the parameters - #[instrument(ret)] - fn as_sql(&self, to_count: bool) -> Result<(String, Vec)> { - let mut clauses = Vec::new(); - let mut params = Vec::new(); - - // Handle filters - if let Some(filters_map) = &self.filter { - let mut where_clauses = Vec::new(); - - for (field, value) in filters_map { - info!("field = ?: {field}, value = ?: {value}"); - let param_name = format!("param_{field}"); - match field { - OrderField::Id => { // sql INTEGER type - let int_value: i64 = value.parse()?; - where_clauses.push(format!("id = @{}", param_name)); - params.push(create_named_param(param_name, Value::N(int_value))); - } - OrderField::SenderPk | OrderField::UserId | OrderField::Username | OrderField::StatementKind | OrderField::Version => { // sql VARCHAR type - where_clauses.push(format!("{field} LIKE @{}", param_name)); - params.push(create_named_param(param_name, Value::S(value.to_string()))); - } - OrderField::StatementTimestamp | OrderField::Created => { // sql TIMESTAMP type - // these have their own column and are inside of Message´s column as well - let datetime = ISO8601::to_date_utc(&value) - .map_err(|err| anyhow!("Failed to parse timestamp: {:?}", err))?; - let ts: i64 = datetime.timestamp(); - let ts_end: i64 = ts + 60; // Search along that minute, the second is not specified by the front. - let param_name_end = format!("{param_name}_end"); - where_clauses.push(format!("{field} >= @{} AND {field} < @{}", param_name, param_name_end)); - params.push(create_named_param(param_name, Value::Ts(ts))); - params.push(create_named_param(param_name_end, Value::Ts(ts_end))); - } - OrderField::EventType | OrderField::LogType | OrderField::Description // these have no column but are inside of Message - | OrderField::Message => {} // Message column is sql BLOB type and it´s encrypted so we can't search it without expensive operations - } - } - - if !where_clauses.is_empty() { - clauses.push(format!("WHERE {}", where_clauses.join(" AND "))); - } - }; - - // Build a single extra clause. - // This clause returns rows if: - // - @election_filter is non-empty and matches election_id, OR - // - @area_filter is non-empty and matches area_id, OR - // - Both election_id and area_id are either '' or NULL. (General to all elections log) - let mut extra_where_clauses = Vec::new(); - if self.election_id.is_some() || self.area_ids.is_some() { - let mut conds = Vec::new(); - - if let Some(election) = &self.election_id { - if !election.is_empty() { - params.push(create_named_param( - "param_election".to_string(), - Value::S(election.clone()), - )); - conds.push("election_id LIKE @param_election".to_string()); - } - } - - if let Some(area_ids) = &self.area_ids { - if !area_ids.is_empty() { - let placeholders: Vec = area_ids - .iter() - .enumerate() - .map(|(i, _)| format!("@param_area{}", i)) - .collect(); - for (i, area) in area_ids.into_iter().enumerate() { - let param_name = format!("param_area{}", i); - params.push(create_named_param( - param_name.clone(), - Value::S(area.clone()), - )); - } - conds.push(format!( - "(@param_area0 <> '' AND area_id IN ({}))", - placeholders.join(", ") - )); - } - } - - // if neither filter matches, return logs where both fields are empty or NULL. - conds.push( - "((election_id = '' OR election_id IS NULL) AND (area_id = '' OR area_id IS NULL))" - .to_string(), - ); - - extra_where_clauses.push(format!("({})", conds.join(" OR "))); - } - - // Handle only_with_user - if self.only_with_user.unwrap_or(false) { - extra_where_clauses.push("(user_id IS NOT NULL AND user_id <> '')".to_string()); - } - - if !extra_where_clauses.is_empty() { - match clauses.len() { - 0 => { - clauses.push(format!("WHERE {}", extra_where_clauses.join(" AND "))); - } - _ => { - let where_clause = clauses.pop().ok_or(anyhow!("Empty clause"))?; - clauses.push(format!( - "{} AND {}", - where_clause, - extra_where_clauses.join(" AND ") - )); - } - } - } - - // Handle order_by - if !to_count && self.order_by.is_some() { - let order_by_clauses: Vec = self - .order_by - .as_ref() - .ok_or(anyhow!("Empty order clause"))? - .iter() - .map(|(field, direction)| format!("{field} {direction}")) - .collect(); - if order_by_clauses.len() > 0 { - clauses.push(format!("ORDER BY {}", order_by_clauses.join(", "))); - } - } - - // Handle limit - if !to_count { - let limit_param_name = String::from("limit"); - let limit_value = self - .limit - .unwrap_or(PgConfig::from_env()?.default_sql_limit.into()); - let limit = std::cmp::min(limit_value, PgConfig::from_env()?.low_sql_limit.into()); - clauses.push(format!("LIMIT @{limit_param_name}")); - params.push(create_named_param(limit_param_name, Value::N(limit))); - } - - // Handle offset - if !to_count && self.offset.is_some() { - let offset_param_name = String::from("offset"); - let offset = std::cmp::max(self.offset.unwrap_or(0), 0); - clauses.push(format!("OFFSET @{}", offset_param_name)); - params.push(create_named_param(offset_param_name, Value::N(offset))); - } - - Ok((clauses.join(" "), params)) - } +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct StatementHeadDataString { + pub event: String, + pub kind: String, + pub timestamp: i64, + pub event_type: String, + pub log_type: String, + pub description: String, } #[derive(Serialize, Deserialize, Debug, Clone)] @@ -932,14 +760,28 @@ pub struct ElectoralLogRow { pub user_id: Option, pub username: Option, } -#[derive(Deserialize, Serialize, Debug, Clone)] -pub struct StatementHeadDataString { - pub event: String, - pub kind: String, - pub timestamp: i64, - pub event_type: String, - pub log_type: String, - pub description: String, + +// Removing this step would inprove the performance, i.e. return the final type directly from ElectoralLogMessage. +impl TryFrom for ElectoralLogRow { + type Error = anyhow::Error; + + fn try_from(elog_msg: ElectoralLogMessage) -> Result { + let serialized = general_purpose::STANDARD_NO_PAD.encode(elog_msg.message.clone()); + let deserialized_message = Message::strand_deserialize(&elog_msg.message) + .map_err(|e| anyhow!("Error deserializing message: {e:?}"))?; + + Ok(ElectoralLogRow { + id: elog_msg.id, + created: elog_msg.created, + statement_timestamp: elog_msg.statement_timestamp, + statement_kind: elog_msg.statement_kind.clone(), + message: serde_json::to_string_pretty(&deserialized_message) + .with_context(|| "Error serializing message to json")?, + data: serialized, + user_id: elog_msg.user_id.clone(), + username: elog_msg.username.clone(), + }) + } } impl ElectoralLogRow { @@ -1001,175 +843,196 @@ impl ElectoralLogRow { } } -impl TryFrom<&Row> for ElectoralLogRow { - type Error = anyhow::Error; +#[derive(Serialize, Deserialize, Debug, Clone, Default)] +pub struct CastVoteEntry { + pub statement_timestamp: i64, + pub statement_kind: String, + pub ballot_id: String, + pub username: Option, +} - fn try_from(row: &Row) -> Result { - let mut id = 0; - let mut created: i64 = 0; - let mut sender_pk = String::from(""); - let mut statement_timestamp: i64 = 0; - let mut statement_kind = String::from(""); - let mut message = vec![]; - let mut user_id = None; - let mut username = None; - - for (column, value) in row.columns.iter().zip(row.values.iter()) { - match column.as_str() { - c if c.ends_with(".id)") => { - assign_value!(Value::N, value, id) - } - c if c.ends_with(".created)") => { - assign_value!(Value::Ts, value, created) - } - c if c.ends_with(".sender_pk)") => { - assign_value!(Value::S, value, sender_pk) - } - c if c.ends_with(".statement_timestamp)") => { - assign_value!(Value::Ts, value, statement_timestamp) - } - c if c.ends_with(".statement_kind)") => { - assign_value!(Value::S, value, statement_kind) - } - c if c.ends_with(".message)") => { - assign_value!(Value::Bs, value, message) - } - c if c.ends_with(".user_id)") => match value.value.as_ref() { - Some(Value::S(inner)) => user_id = Some(inner.clone()), - Some(Value::Null(_)) => user_id = None, - None => user_id = None, - _ => return Err(anyhow!("invalid column value for 'user_id'")), - }, - c if c.ends_with(".username)") => match value.value.as_ref() { - Some(Value::S(inner)) => username = Some(inner.clone()), - Some(Value::Null(_)) => username = None, - None => username = None, - _ => return Err(anyhow!("invalid column value for 'username'")), - }, - _ => return Err(anyhow!("invalid column found '{}'", column.as_str())), - } - } - let deserialized_message = - Message::strand_deserialize(&message).with_context(|| "Error deserializing message")?; - let serialized = general_purpose::STANDARD_NO_PAD.encode(message); - Ok(ElectoralLogRow { - id, - created, - statement_timestamp, - statement_kind, - message: serde_json::to_string_pretty(&deserialized_message) - .with_context(|| "Error serializing message to json")?, - data: serialized, - user_id, +#[derive(Serialize, Deserialize, Debug, Clone, Default)] +pub struct CastVoteMessagesOutput { + pub list: Vec, + pub total: usize, +} + +impl CastVoteEntry { + pub fn from_elog_message( + entry: &ElectoralLogMessage, + input_username: &str, + ) -> Result, anyhow::Error> { + let ballot_id = entry.ballot_id.clone().unwrap_or_default(); + let username = entry.username.clone().filter(|s| s.eq(input_username)); // Keep other usernames anonymous on the table + Ok(Some(CastVoteEntry { + statement_timestamp: entry.statement_timestamp, + statement_kind: StatementType::CastVote.to_string(), + ballot_id, username, - }) + })) } } -pub const IMMUDB_ROWS_LIMIT: usize = 25_000; - #[instrument(err)] pub async fn list_electoral_log(input: GetElectoralLogBody) -> Result> { - let mut client: Client = get_immudb_client().await?; + let mut client = get_board_client().await?; let board_name = get_event_board(input.tenant_id.as_str(), input.election_event_id.as_str()); + info!("database name = {board_name}"); + let cols_match_select = input.as_where_clause_map()?; + let order_by = input.order_by.clone(); + let (min_ts, max_ts) = input.get_min_max_ts()?; + let limit: i64 = input.limit.unwrap_or(IMMUDB_ROWS_LIMIT as i64); + let offset: i64 = input.offset.unwrap_or(0); + let mut rows: Vec = vec![]; + let electoral_log_messages = client + .get_electoral_log_messages_filtered( + &board_name, + Some(cols_match_select.clone()), + min_ts, + max_ts, + Some(limit), + Some(offset), + order_by.clone(), + ) + .await + .map_err(|err| anyhow!("Failed to get filtered messages: {:?}", err))?; - event!(Level::INFO, "database name = {board_name}"); - info!("input = {:?}", input); - client.open_session(&board_name).await?; - let (clauses, params) = input.as_sql(false)?; - let (clauses_to_count, count_params) = input.as_sql(true)?; - info!("clauses ?:= {clauses}"); - let sql = format!( - r#" - SELECT - id, - created, - sender_pk, - statement_timestamp, - statement_kind, - message, - user_id, - username - FROM electoral_log_messages - {clauses} - "#, - ); - info!("query: {sql}"); - let sql_query_response = client.streaming_sql_query(&sql, params).await?; - - let limit: usize = input.limit.unwrap_or(IMMUDB_ROWS_LIMIT as i64).try_into()?; - - let mut rows: Vec = Vec::with_capacity(limit); - let mut resp_stream = sql_query_response.into_inner(); - while let Some(streaming_batch) = resp_stream.next().await { - let items = streaming_batch? - .rows - .iter() - .map(ElectoralLogRow::try_from) - .collect::>>()?; - rows.extend(items); + let t_entries = electoral_log_messages.len(); + info!("Got {t_entries} entries. Offset: {offset}, limit: {limit}"); + for message in electoral_log_messages { + rows.push(message.try_into()?); } - let sql = format!( - r#" - SELECT - COUNT(*) - FROM electoral_log_messages - {clauses_to_count} - "#, - ); - let sql_query_response = client.sql_query(&sql, count_params).await?; - let mut rows_iter = sql_query_response - .get_ref() - .rows - .iter() - .map(Aggregate::try_from); - - let aggregate = rows_iter - // get the first item - .next() - // unwrap the Result and Option - .ok_or(anyhow!("No aggregate found"))??; - - client.close_session().await?; Ok(DataList { items: rows, total: TotalAggregate { - aggregate: aggregate, + aggregate: Aggregate { + count: t_entries as i64, + }, }, }) } -#[instrument(err)] -pub async fn count_electoral_log(input: GetElectoralLogBody) -> Result { - let mut client = get_immudb_client().await?; - let board_name = get_event_board(input.tenant_id.as_str(), input.election_event_id.as_str()); - - info!("board name: {board_name}"); - client.open_session(&board_name).await?; - - let (clauses_to_count, count_params) = input.as_sql(true)?; - let sql = format!( - r#" - SELECT COUNT(*) - FROM electoral_log_messages - {clauses_to_count} - "#, +/// Returns the entries for statement_kind = "CastVote" which ballot_id matches the input. +/// ballot_id_filter is restricted to be an even number of characters, so thatnit can be converted +/// to a byte array. +#[instrument(err, skip_all)] +pub async fn list_cast_vote_messages_and_count( + input: GetElectoralLogBody, + ballot_id_filter: &str, + user_id: &str, + username: &str, +) -> Result { + ensure!( + ballot_id_filter.chars().count() % 2 == 0 && ballot_id_filter.is_ascii(), + "Incorrect ballot_id, the length must be an even number of characters" + ); + let election_id = input.election_id.clone().unwrap_or_default(); + let (cols_match_count, cols_match_select) = + input.as_cast_vote_count_and_select_clauses(&election_id, user_id, ballot_id_filter); + + let (data_res, count_res) = tokio::join!( + list_cast_vote_messages( + input.clone(), + ballot_id_filter, + user_id, + username, + cols_match_select + ), + async { + let mut client = get_board_client().await?; + let board_name = + get_event_board(input.tenant_id.as_str(), input.election_event_id.as_str()); + info!("database name = {board_name}"); + let total: usize = client + .count_electoral_log_messages(&board_name, Some(cols_match_count)) + .await? + .to_usize() + .unwrap_or(0); + Ok(total) + } ); - info!("query: {sql}"); + let mut data = data_res.map_err(|e| anyhow!("Eror listing electoral log: {e:?}"))?; + data.total = + count_res.map_err(|e: anyhow::Error| anyhow!("Error counting electoral log: {e:?}"))?; - let sql_query_response = client.sql_query(&sql, count_params).await?; + Ok(data) +} - let mut rows_iter = sql_query_response - .get_ref() - .rows - .iter() - .map(Aggregate::try_from); - let aggregate = rows_iter - .next() - .ok_or_else(|| anyhow!("No aggregate found"))??; +#[instrument(err)] +pub async fn list_cast_vote_messages( + input: GetElectoralLogBody, + ballot_id_filter: &str, + user_id: &str, + username: &str, + cols_match_select: WhereClauseOrdMap, +) -> Result { + // The limits are used to cut the output after filtering the ballot id. + // Because ballot_id cannot be filtered at SQL level the sql limit is constant + let output_limit: i64 = input.limit.unwrap_or(MAX_ROWS_PER_PAGE as i64); + let board_name = get_event_board(input.tenant_id.as_str(), input.election_event_id.as_str()); + info!("database name = {board_name}"); + let order_by = input.order_by.clone(); + + let limit: i64 = match ballot_id_filter.is_empty() { + false => IMMUDB_ROWS_LIMIT as i64, // When there is a filter, need to fetch all entries by batches. + true => input.limit.unwrap_or(MAX_ROWS_PER_PAGE as i64), + }; + let mut offset: i64 = input.offset.unwrap_or(0); + let mut list: Vec = Vec::with_capacity(MAX_ROWS_PER_PAGE); // Filtered messages. + + let mut client = get_board_client().await?; + let mut exit = false; // Exit at the first match if the filter is not empty or when the query returns 0 entries + while (list.len() as i64) < output_limit && !exit { + let electoral_log_messages = client + .get_electoral_log_messages_filtered( + &board_name, + Some(cols_match_select.clone()), + None, + None, + Some(limit), + Some(offset), + order_by.clone(), + ) + .await + .map_err(|err| anyhow!("Failed to get filtered messages: {:?}", err))?; + + let t_entries = electoral_log_messages.len(); + info!("Got {t_entries} entries. Offset: {offset}, limit: {limit}"); + for message in electoral_log_messages.iter() { + match CastVoteEntry::from_elog_message(&message, username)? { + Some(entry) if !ballot_id_filter.is_empty() => { + // If there is filter exit at the first match + exit = true; + list.push(entry); + } + Some(entry) => { + // Add all the entries till the limit, when there is no filter + list.push(entry); + } + None => {} + } + if (list.len() as i64) >= output_limit || exit { + break; + } + } + exit = exit || t_entries == 0; + offset += limit; + } + Ok(CastVoteMessagesOutput { list, total: 0 }) +} - client.close_session().await?; - Ok(aggregate.count as i64) +#[instrument(err)] +pub async fn count_electoral_log(input: GetElectoralLogBody) -> Result { + let mut client = get_board_client().await?; + let board_name = get_event_board(input.tenant_id.as_str(), input.election_event_id.as_str()); + info!("database name = {board_name}"); + let cols_match_count = input.as_where_clause_map()?; + let total = client + .count_electoral_log_messages(&board_name, Some(cols_match_count)) + .await? + .to_u64() + .unwrap_or(0) as i64; + Ok(total) } diff --git a/packages/windmill/src/services/event_list.rs b/packages/windmill/src/services/event_list.rs index d3289dcce0d..3ab2239cf4d 100644 --- a/packages/windmill/src/services/event_list.rs +++ b/packages/windmill/src/services/event_list.rs @@ -2,12 +2,10 @@ // // SPDX-License-Identifier: AGPL-3.0-only use crate::postgres::election_event::get_election_event_by_id; -use crate::{ - postgres::scheduled_event::{insert_new_scheduled_event, insert_scheduled_event}, - types::resources::OrderDirection, -}; +use crate::postgres::scheduled_event::{insert_new_scheduled_event, insert_scheduled_event}; use anyhow::Result; use deadpool_postgres::Transaction; +use electoral_log::client::types::OrderDirection; use rocket::http::Status; use sequent_core::services::keycloak; use sequent_core::types::hasura::core::ElectionEvent; diff --git a/packages/windmill/src/services/export/export_election_event.rs b/packages/windmill/src/services/export/export_election_event.rs index b04af050f49..de86928a0ec 100644 --- a/packages/windmill/src/services/export/export_election_event.rs +++ b/packages/windmill/src/services/export/export_election_event.rs @@ -2,6 +2,11 @@ use crate::postgres::application::get_applications_by_election; // SPDX-FileCopyrightText: 2024 Felix Robles // // SPDX-License-Identifier: AGPL-3.0-only +use super::export_bulletin_boards; +use super::export_schedule_events; +use super::export_tally; +use super::export_users::export_users_file; +use super::export_users::ExportBody; use crate::postgres::area::get_event_areas; use crate::postgres::area_contest::export_area_contests; use crate::postgres::ballot_publication::get_ballot_publication; @@ -12,10 +17,15 @@ use crate::postgres::election_event::get_election_event_by_id; use crate::postgres::keys_ceremony::get_keys_ceremonies; use crate::postgres::reports::get_reports_by_election_event_id; use crate::postgres::trustee::get_all_trustees; +use crate::services::consolidation::aes_256_cbc_encrypt::encrypt_file_aes_256_cbc; use crate::services::database::get_hasura_pool; +use crate::services::database::PgConfig; +use crate::services::documents::upload_and_return_document; +use crate::services::electoral_log::ElectoralLogRow; use crate::services::export::export_ballot_publication; use crate::services::import::import_election_event::ImportElectionEventSchema; -use crate::services::reports::activity_log; +use crate::services::password; +use crate::services::reports::activity_log::{self, ActivityLogRow}; use crate::services::reports::activity_log::{ActivityLogsTemplate, ReportFormat}; use crate::services::reports::template_renderer::{ ReportOriginatedFrom, ReportOrigins, TemplateRenderer, @@ -23,7 +33,6 @@ use crate::services::reports::template_renderer::{ use crate::services::reports_vault::get_password; use crate::tasks::export_election_event::ExportOptions; use crate::types::documents::EDocuments; - use anyhow::{anyhow, Context, Result}; use deadpool_postgres::{Client as DbClient, Transaction}; use futures::try_join; @@ -43,15 +52,6 @@ use tracing::{event, info, instrument, Level}; use uuid::Uuid; use zip::write::FileOptions; -use super::export_bulletin_boards; -use super::export_schedule_events; -use super::export_tally; -use super::export_users::export_users_file; -use super::export_users::ExportBody; -use crate::services::consolidation::aes_256_cbc_encrypt::encrypt_file_aes_256_cbc; -use crate::services::documents::upload_and_return_document; -use crate::services::password; - #[instrument(err, skip(transaction))] pub async fn read_export_data( transaction: &Transaction<'_>, @@ -357,17 +357,11 @@ pub async fn process_export_zip( ReportFormat::CSV, // Assuming CSV format for this export ); - // Prepare user data - let user_data = activity_logs_template - .prepare_user_data(&hasura_transaction, &hasura_transaction) - .await - .map_err(|e| anyhow!("Error preparing activity logs data: {e:?}"))?; - // Generate the CSV file using generate_export_data - let temp_activity_logs_file = - activity_log::generate_export_data(&user_data.electoral_log, &activity_logs_filename) - .await - .map_err(|e| anyhow!("Error generating export data: {e:?}"))?; + let temp_activity_logs_file = activity_logs_template + .generate_export_csv_data(&activity_logs_filename) + .await + .map_err(|e| anyhow!("Error generating export data: {e:?}"))?; zip_writer .start_file(&activity_logs_filename, options) diff --git a/packages/windmill/src/services/insert_cast_vote.rs b/packages/windmill/src/services/insert_cast_vote.rs index 958b6976c82..2702f4e6a04 100644 --- a/packages/windmill/src/services/insert_cast_vote.rs +++ b/packages/windmill/src/services/insert_cast_vote.rs @@ -899,7 +899,7 @@ async fn check_previous_votes( .filter_map(|cv| cv.area_id.and_then(|id| Uuid::parse_str(&id).ok())) .partition(|cv_area_id| cv_area_id.to_string() == area_id.to_string()); - event!(Level::INFO, "get cast votes returns same: {:?}", same); + event!(Level::DEBUG, "get cast votes returns same: {:?}", same); // Skip max votes check if max_revotes is 0, allowing unlimited votes if max_revotes > 0 && same.len() >= max_revotes { diff --git a/packages/windmill/src/services/protocol_manager.rs b/packages/windmill/src/services/protocol_manager.rs index 0c86aa47570..2b5067d5504 100644 --- a/packages/windmill/src/services/protocol_manager.rs +++ b/packages/windmill/src/services/protocol_manager.rs @@ -419,7 +419,7 @@ pub async fn get_board_client() -> Result { let password = env::var("IMMUDB_PASSWORD").context("IMMUDB_PASSWORD must be set")?; let server_url = env::var("IMMUDB_SERVER_URL").context("IMMUDB_SERVER_URL must be set")?; - let mut board_client = BoardClient::new(&server_url, &username, &password).await?; + let board_client = BoardClient::new(&server_url, &username, &password).await?; Ok(board_client) } diff --git a/packages/windmill/src/services/reports/activity_log.rs b/packages/windmill/src/services/reports/activity_log.rs index ea0f309cfe4..93afdf96208 100644 --- a/packages/windmill/src/services/reports/activity_log.rs +++ b/packages/windmill/src/services/reports/activity_log.rs @@ -4,30 +4,32 @@ use super::template_renderer::*; use crate::postgres::reports::{Report, ReportType}; -use crate::services::database::PgConfig; use crate::services::documents::upload_and_return_document; -use crate::services::electoral_log::{ - count_electoral_log, list_electoral_log, ElectoralLogRow, GetElectoralLogBody, -}; +use crate::services::electoral_log::{ElectoralLogRow, IMMUDB_ROWS_LIMIT}; +use crate::services::protocol_manager::{get_board_client, get_event_board}; use crate::services::providers::email_sender::{Attachment, EmailSender}; -use crate::services::temp_path::*; -use crate::types::resources::DataList; use anyhow::{anyhow, Context, Result}; use async_trait::async_trait; use csv::WriterBuilder; use deadpool_postgres::Transaction; +use electoral_log::client::types::*; +use electoral_log::messages::message::{Message, SigningData}; use sequent_core::services::date::ISO8601; -use sequent_core::services::keycloak::{self}; use sequent_core::services::s3::get_minio_url; use sequent_core::types::hasura::core::TasksExecution; use sequent_core::types::templates::{ReportExtraConfig, SendTemplateBody}; use sequent_core::util::temp_path::*; use serde::{Deserialize, Serialize}; +use std::mem; +use strand::serialization::StrandDeserialize; use strum_macros::EnumString; use tempfile::NamedTempFile; use tracing::{debug, info, instrument, warn}; -#[derive(Serialize, Deserialize, Debug, Clone, EnumString, PartialEq)] +const KB: f64 = 1024.0; +const MB: f64 = 1024.0 * KB; + +#[derive(Serialize, Deserialize, Debug, Clone, EnumString, PartialEq, Copy)] pub enum ReportFormat { CSV, PDF, @@ -46,43 +48,17 @@ pub struct ActivityLogRow { user_id: String, } -/// Struct for User Data -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct UserData { - pub act_log: Vec, - pub electoral_log: Vec, -} - -/// Struct for System Data -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct SystemData { - pub rendered_user_template: String, -} - -/// Implementation of TemplateRenderer for Activity Logs -#[derive(Debug)] -pub struct ActivityLogsTemplate { - ids: ReportOrigins, - report_format: ReportFormat, -} - -impl ActivityLogsTemplate { - pub fn new(ids: ReportOrigins, report_format: ReportFormat) -> Self { - ActivityLogsTemplate { ids, report_format } - } -} - -impl TryFrom for ActivityLogRow { +impl TryFrom for ActivityLogRow { type Error = anyhow::Error; - fn try_from(electoral_log: ElectoralLogRow) -> Result { - let user_id = match electoral_log.user_id() { + fn try_from(electoral_log: ElectoralLogMessage) -> Result { + let user_id = match electoral_log.user_id { Some(user_id) => user_id.to_string(), None => "-".to_string(), }; let statement_timestamp: String = if let Ok(datetime_parsed) = - ISO8601::timestamp_secs_utc_to_date_opt(electoral_log.statement_timestamp()) + ISO8601::timestamp_secs_utc_to_date_opt(electoral_log.statement_timestamp) { datetime_parsed.to_rfc3339() } else { @@ -90,34 +66,119 @@ impl TryFrom for ActivityLogRow { }; let created: String = if let Ok(datetime_parsed) = - ISO8601::timestamp_secs_utc_to_date_opt(electoral_log.created()) + ISO8601::timestamp_secs_utc_to_date_opt(electoral_log.created) { datetime_parsed.to_rfc3339() } else { return Err(anyhow::anyhow!("Error parsing created")); }; - let head_data = electoral_log - .statement_head_data() - .with_context(|| "Error to get head data.")?; - let event_type = head_data.event_type; - let log_type = head_data.log_type; + let deserialized_message = Message::strand_deserialize(&electoral_log.message) + .map_err(|e| anyhow!("Error deserializing message: {e:?}"))?; + + let head_data = deserialized_message.statement.head.clone(); + let event_type = head_data.event_type.to_string(); + let log_type = head_data.log_type.to_string(); let description = head_data.description; Ok(ActivityLogRow { - id: electoral_log.id(), + id: electoral_log.id, user_id: user_id, created, statement_timestamp, - statement_kind: electoral_log.statement_kind().to_string(), + statement_kind: electoral_log.statement_kind, event_type, log_type, description, - message: electoral_log.message().to_string(), + message: deserialized_message.to_string(), }) } } +/// Struct for User Data +/// act_log is for PDF +/// electoral_log is for CSV +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct UserData { + pub act_log: Vec, + pub electoral_log: Vec, +} + +/// Struct for System Data +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct SystemData { + pub rendered_user_template: String, +} + +/// Implementation of TemplateRenderer for Activity Logs +#[derive(Debug)] +pub struct ActivityLogsTemplate { + ids: ReportOrigins, + report_format: ReportFormat, + board_name: String, +} + +impl ActivityLogsTemplate { + pub fn new(ids: ReportOrigins, report_format: ReportFormat) -> Self { + let board_name = get_event_board(ids.tenant_id.as_str(), ids.election_event_id.as_str()); + ActivityLogsTemplate { + ids, + report_format, + board_name, + } + } + + // Export data + #[instrument(err, skip(self))] + pub async fn generate_export_csv_data(&self, name: &str) -> Result { + let limit = IMMUDB_ROWS_LIMIT as i64; + let mut offset: i64 = 0; + // Create a temporary file to write CSV data + let mut temp_file = + generate_temp_file(&name, ".csv").with_context(|| "Error creating named temp file")?; + let mut csv_writer = WriterBuilder::new().from_writer(temp_file.as_file_mut()); + let total = self + .count_items(None) + .await + .map_err(|e| anyhow!("Error count_items in activity logs data: {e:?}"))? + .unwrap_or(0); + while offset < total { + info!("offset: {offset}, total: {total}"); + // Prepare user data + let user_data = self + .prepare_user_data_batch(None, None, &mut offset, limit) + .await + .map_err(|e| anyhow!("Error preparing activity logs data: {e:?}"))?; + + let s1 = user_data.electoral_log.len() * (mem::size_of::()); + let s2 = user_data.act_log.len() * (mem::size_of::()); + let kb = (s1 + s2) as f64 / KB; + let mb = (s1 + s2) as f64 / MB; + info!("Logs batch size: {kb:.2} KB, {mb:.2} MB"); + + for item in user_data.electoral_log { + let mut item_clone = item.clone(); + + // Replace newline characters in the message field + item_clone.message = item_clone.message.replace('\n', " ").replace('\r', " "); + // Serialize each item to CSV + csv_writer + .serialize(item_clone) + .map_err(|e| anyhow!("Error serializing to CSV: {e:?}"))?; + } + offset += limit; + } + + // Flush and finish writing to the temporary file + csv_writer + .flush() + .map_err(|e| anyhow!("Error flushing CSV writer: {e:?}"))?; + drop(csv_writer); + + Ok(temp_file) + } +} + #[async_trait] impl TemplateRenderer for ActivityLogsTemplate { type UserData = UserData; @@ -150,137 +211,61 @@ impl TemplateRenderer for ActivityLogsTemplate { fn prefix(&self) -> String { format!("activity_logs_{}", rand::random::()) } - async fn count_items(&self, _hasura_transaction: &Transaction<'_>) -> Result> { - let input = GetElectoralLogBody { - tenant_id: self.ids.tenant_id.clone(), - election_event_id: self.ids.election_event_id.clone(), - limit: None, - offset: None, - filter: None, - order_by: None, - area_ids: None, - only_with_user: None, - election_id: None, - }; - Ok(count_electoral_log(input).await.ok()) + + async fn count_items( + &self, + _hasura_transaction: Option<&Transaction<'_>>, + ) -> Result> { + let mut client = get_board_client().await?; + let total = client + .count_electoral_log_messages(&self.board_name, None) + .await + .map_err(|e| anyhow!("Error counting electoral log messages: {e:?}"))?; + Ok(Some(total)) } + #[instrument(err, skip_all)] async fn prepare_user_data_batch( &self, - _hasura_transaction: &Transaction<'_>, - _keycloak_transaction: &Transaction<'_>, + _hasura_transaction: Option<&Transaction<'_>>, + _keycloak_transaction: Option<&Transaction<'_>>, offset: &mut i64, limit: i64, ) -> Result { let mut act_log: Vec = vec![]; - let mut elect_logs: Vec = vec![]; - - let electoral_logs: DataList = list_electoral_log(GetElectoralLogBody { - tenant_id: self.ids.tenant_id.clone(), - election_event_id: self.ids.election_event_id.clone(), - limit: Some(limit), - offset: Some(*offset), - filter: None, - order_by: None, - area_ids: None, - only_with_user: None, - election_id: None, - }) - .await - .map_err(|e| anyhow!("Error listing electoral logs: {e:?}"))?; - - let is_empty = electoral_logs.items.is_empty(); - - for electoral_log in electoral_logs.items { - elect_logs.push(electoral_log.clone()); - let head_data = electoral_log - .statement_head_data() - .with_context(|| "Error to get head data.")?; - let event_type = head_data.event_type; - let log_type = head_data.log_type; - let description = head_data.description; - let activity_log = electoral_log.try_into()?; - info!("activity_log = {activity_log:?}"); - let activity_log = ActivityLogRow { - event_type, - log_type, - description, - ..activity_log - }; - info!("activity_log = {activity_log:?}"); - act_log.push(activity_log); + let mut electoral_log: Vec = vec![]; + let mut client = get_board_client().await?; + let electoral_log_msgs = client + .get_electoral_log_messages_batch(&self.board_name, limit, *offset) + .await + .map_err(|err| anyhow!("Failed to get filtered messages: {:?}", err))?; + info!("Format: {:#?}", self.report_format); + for entry in electoral_log_msgs { + match self.report_format { + ReportFormat::PDF => { + act_log.push(entry.try_into()?); + } + ReportFormat::CSV => { + electoral_log.push(entry.clone().try_into()?); + } + } } - let total = electoral_logs.total.aggregate.count; - Ok(UserData { act_log, - electoral_log: elect_logs, + electoral_log, }) } + #[instrument(err, skip_all)] async fn prepare_user_data( &self, _hasura_transaction: &Transaction<'_>, _keycloak_transaction: &Transaction<'_>, ) -> Result { - let mut act_log: Vec = vec![]; - let mut elect_logs: Vec = vec![]; - let mut offset = 0; - let limit = PgConfig::from_env() - .with_context(|| "Error obtaining Pg config from env.")? - .default_sql_batch_size as i64; - - loop { - let electoral_logs: DataList = - list_electoral_log(GetElectoralLogBody { - tenant_id: self.ids.tenant_id.clone(), - election_event_id: self.ids.election_event_id.clone(), - limit: Some(limit), - offset: Some(offset), - filter: None, - order_by: None, - area_ids: None, - only_with_user: None, - election_id: None, - }) - .await - .map_err(|e| anyhow!("Error listing electoral logs: {e:?}"))?; - - let is_empty = electoral_logs.items.is_empty(); - - for electoral_log in electoral_logs.items { - elect_logs.push(electoral_log.clone()); - let head_data = electoral_log - .statement_head_data() - .with_context(|| "Error to get head data.")?; - let event_type = head_data.event_type; - let log_type = head_data.log_type; - let description = head_data.description; - let activity_log = electoral_log.try_into()?; - info!("activity_log = {activity_log:?}"); - let activity_log = ActivityLogRow { - event_type, - log_type, - description, - ..activity_log - }; - info!("activity_log = {activity_log:?}"); - act_log.push(activity_log); - } - - let total = electoral_logs.total.aggregate.count; - if is_empty || offset >= total { - break; - } - - offset += limit; - } - - Ok(UserData { - act_log, - electoral_log: elect_logs, - }) + Err(anyhow!( + "prepare_user_data should not be used for this report type, use prepare_user_data_batch instead" + )) } #[instrument(err, skip_all)] @@ -327,16 +312,10 @@ impl TemplateRenderer for ActivityLogsTemplate { ) .await } else { - // Generate CSV report - // Prepare user data - let user_data = self - .prepare_user_data(hasura_transaction, keycloak_transaction) - .await - .map_err(|e| anyhow!("Error preparing activity logs data into CSV: {e:?}"))?; - - // Generate CSV file using generate_report_data + // Generate CSV file using generate_export_csv_data let name = format!("export-election-event-logs-{}", election_event_id); - let temp_file = generate_report_data(&user_data.act_log, &name) + let temp_file = self + .generate_export_csv_data(&name) .await .map_err(|e| anyhow!("Error generating export data: {e:?}"))?; @@ -410,61 +389,3 @@ impl TemplateRenderer for ActivityLogsTemplate { } } } - -/// Maintains the generate_export_data function as before. -/// This function can be used by other report types that need to generate CSV files. -#[instrument(err, skip(act_log))] -pub async fn generate_report_data(act_log: &[ActivityLogRow], name: &str) -> Result { - // Create a temporary file to write CSV data - let mut temp_file = - generate_temp_file(&name, ".csv").with_context(|| "Error creating named temp file")?; - let mut csv_writer = WriterBuilder::new().from_writer(temp_file.as_file_mut()); - - for item in act_log { - let mut item_clean = item.clone(); - - // Replace newline characters in the message field - item_clean.message = item_clean.message.replace('\n', " ").replace('\r', " "); - // Serialize each item to CSV - csv_writer - .serialize(item_clean) - .map_err(|e| anyhow!("Error serializing to CSV: {e:?}"))?; - } - // Flush and finish writing to the temporary file - csv_writer - .flush() - .map_err(|e| anyhow!("Error flushing CSV writer: {e:?}"))?; - drop(csv_writer); - - Ok(temp_file) -} - -// Export data -#[instrument(err, skip(act_log))] -pub async fn generate_export_data( - act_log: &[ElectoralLogRow], - name: &str, -) -> Result { - // Create a temporary file to write CSV data - let mut temp_file = - generate_temp_file(&name, ".csv").with_context(|| "Error creating named temp file")?; - let mut csv_writer = WriterBuilder::new().from_writer(temp_file.as_file_mut()); - - for item in act_log { - let mut item_clean = item.clone(); - - // Replace newline characters in the message field - item_clean.message = item_clean.message.replace('\n', " ").replace('\r', " "); - // Serialize each item to CSV - csv_writer - .serialize(item_clean) - .map_err(|e| anyhow!("Error serializing to CSV: {e:?}"))?; - } - // Flush and finish writing to the temporary file - csv_writer - .flush() - .map_err(|e| anyhow!("Error flushing CSV writer: {e:?}"))?; - drop(csv_writer); - - Ok(temp_file) -} diff --git a/packages/windmill/src/services/reports/audit_logs.rs b/packages/windmill/src/services/reports/audit_logs.rs index 7ea9c78bf0f..9c95a9827c5 100644 --- a/packages/windmill/src/services/reports/audit_logs.rs +++ b/packages/windmill/src/services/reports/audit_logs.rs @@ -20,8 +20,7 @@ use crate::services::database::PgConfig; use crate::services::documents::upload_and_return_document; use crate::services::election_dates::get_election_dates; use crate::services::electoral_log::{ - count_electoral_log, list_electoral_log, ElectoralLogRow, GetElectoralLogBody, - IMMUDB_ROWS_LIMIT, + count_electoral_log, list_electoral_log, ElectoralLogRow, IMMUDB_ROWS_LIMIT, }; use crate::services::providers::email_sender::{Attachment, EmailSender}; use crate::services::tasks_execution::{update_complete, update_fail}; @@ -39,9 +38,9 @@ use anyhow::{anyhow, Context, Result}; use async_trait::async_trait; use chrono::{DateTime, Local}; use deadpool_postgres::Transaction; +use electoral_log::client::types::*; use futures::executor::block_on; use once_cell::sync::Lazy; -use rand::seq; use rayon::iter::{IntoParallelIterator, ParallelIterator}; use rayon::ThreadPoolBuilder; use sequent_core::ballot::StringifiedPeriodDates; @@ -102,7 +101,7 @@ pub struct SystemData { #[derive(Debug)] pub struct AuditLogsTemplate { - ids: ReportOrigins, + ids: ReportOrigins, // TODO: It should have board_name like in ActivityLogsTemplate to use them in the traits to get the data from the electoral log } impl AuditLogsTemplate { @@ -322,11 +321,14 @@ impl TemplateRenderer for AuditLogsTemplate { fn prefix(&self) -> String { format!("audit_logs_{}", self.ids.election_event_id) } - async fn count_items(&self, hasura_transaction: &Transaction<'_>) -> Result> { - let Some(election_id) = self.get_election_id() else { - return Err(anyhow!("Empty election_id")); - }; + #[instrument(err, skip_all)] + async fn count_items( + &self, + hasura_transaction: Option<&Transaction<'_>>, + ) -> Result> { + let election_id = self.get_election_id().ok_or(anyhow!("Empty election_id"))?; + let hasura_transaction = hasura_transaction.ok_or(anyhow!("None hasura_transaction"))?; let election_areas = get_areas_by_election_id( &hasura_transaction, &self.ids.tenant_id, @@ -348,6 +350,7 @@ impl TemplateRenderer for AuditLogsTemplate { area_ids: Some(area_ids), only_with_user: Some(true), election_id: Some(election_id), + statement_kind: None, }; Ok(count_electoral_log(input).await.ok()) } @@ -355,8 +358,8 @@ impl TemplateRenderer for AuditLogsTemplate { #[instrument(err, skip_all)] async fn prepare_user_data_batch( &self, - hasura_transaction: &Transaction<'_>, - keycloak_transaction: &Transaction<'_>, + hasura_transaction: Option<&Transaction<'_>>, + keycloak_transaction: Option<&Transaction<'_>>, offset: &mut i64, limit: i64, ) -> Result { @@ -364,6 +367,9 @@ impl TemplateRenderer for AuditLogsTemplate { "Preparing data of audit logs report with {} {} ", &offset, &limit ); + let hasura_transaction = hasura_transaction.ok_or(anyhow!("None hasura_transaction"))?; + let keycloak_transaction = + keycloak_transaction.ok_or(anyhow!("None hasura_transaction"))?; let mut user_data = self .prepare_user_data_common(hasura_transaction, keycloak_transaction) .await?; @@ -475,6 +481,7 @@ impl TemplateRenderer for AuditLogsTemplate { election_id: Some(election_id.clone()), area_ids: Some(area_ids.clone()), only_with_user: Some(true), + statement_kind: None, }; let electoral_logs_batch = list_electoral_log(input) @@ -687,6 +694,7 @@ impl TemplateRenderer for AuditLogsTemplate { area_ids: Some(area_ids.clone()), only_with_user: Some(true), election_id: Some(election_id.clone()), + statement_kind: None, }) .await .with_context(|| "Error in fetching list of electoral logs")?; @@ -694,8 +702,8 @@ impl TemplateRenderer for AuditLogsTemplate { let batch_size = electoral_logs_batch.items.len(); offset += batch_size as i64; electoral_logs.items.extend(electoral_logs_batch.items); - electoral_logs.total.aggregate.count = electoral_logs_batch.total.aggregate.count; if batch_size < IMMUDB_ROWS_LIMIT { + electoral_logs.total.aggregate.count = electoral_logs.items.len() as i64; break; } } @@ -816,7 +824,10 @@ impl TemplateRenderer for AuditLogsTemplate { anyhow!("Error providing the user template and extra config: {e:?}") })?; - let items_count = self.count_items(&hasura_transaction).await?.unwrap_or(0); + let items_count = self + .count_items(Some(&hasura_transaction)) + .await? + .unwrap_or(0); let report_options = ext_cfg.report_options.clone(); let per_report_limit = report_options .max_items_per_report diff --git a/packages/windmill/src/services/reports/template_renderer.rs b/packages/windmill/src/services/reports/template_renderer.rs index f287932bc69..e810ce764e7 100644 --- a/packages/windmill/src/services/reports/template_renderer.rs +++ b/packages/windmill/src/services/reports/template_renderer.rs @@ -111,13 +111,16 @@ pub trait TemplateRenderer: Debug { /// or from other place than the reports TAB. fn get_initial_template_alias(&self) -> Option; - async fn count_items(&self, hasura_transaction: &Transaction<'_>) -> Result> { + async fn count_items( + &self, + hasura_transaction: Option<&Transaction<'_>>, + ) -> Result> { Ok(None) } async fn prepare_user_data_batch( &self, - hasura_transaction: &Transaction<'_>, - keycloak_transaction: &Transaction<'_>, + hasura_transaction: Option<&Transaction<'_>>, + keycloak_transaction: Option<&Transaction<'_>>, offset: &mut i64, limit: i64, ) -> Result { @@ -384,9 +387,14 @@ pub trait TemplateRenderer: Debug { } else { if let (Some(o), Some(l)) = (offset, limit) { info!("Batched processing: offset = {o}, limit = {l}"); - self.prepare_user_data_batch(hasura_transaction, keycloak_transaction, o, l) - .await - .map_err(|e| anyhow!("Error preparing batched user data: {e:?}"))? + self.prepare_user_data_batch( + Some(hasura_transaction), + Some(keycloak_transaction), + o, + l, + ) + .await + .map_err(|e| anyhow!("Error preparing batched user data: {e:?}"))? } else { self.prepare_user_data(hasura_transaction, keycloak_transaction) .await @@ -499,7 +507,10 @@ pub trait TemplateRenderer: Debug { anyhow!("Error providing the user template and extra config: {e:?}") })?; - let items_count = self.count_items(&hasura_transaction).await?.unwrap_or(0); + let items_count = self + .count_items(Some(&hasura_transaction)) + .await? + .unwrap_or(0); let report_options = ext_cfg.report_options.clone(); let per_report_limit = report_options .max_items_per_report diff --git a/packages/windmill/src/tasks/activity_logs_report.rs b/packages/windmill/src/tasks/activity_logs_report.rs index 2196dba3c37..ecb389e7210 100644 --- a/packages/windmill/src/tasks/activity_logs_report.rs +++ b/packages/windmill/src/tasks/activity_logs_report.rs @@ -19,7 +19,7 @@ use crate::{ use anyhow::{anyhow, Context}; use celery::error::TaskError; use deadpool_postgres::Client as DbClient; -use tracing::instrument; +use tracing::{info, instrument}; #[instrument(err)] #[wrap_map_err::wrap_map_err(TaskError)] @@ -68,6 +68,7 @@ pub async fn generate_activity_logs_report( format, ); + info!("Generating activity logs report"); let _ = report .execute_report( &document_id, diff --git a/packages/windmill/src/tasks/electoral_log.rs b/packages/windmill/src/tasks/electoral_log.rs index 7fa04b6b364..be70412244d 100644 --- a/packages/windmill/src/tasks/electoral_log.rs +++ b/packages/windmill/src/tasks/electoral_log.rs @@ -15,13 +15,12 @@ use crate::types::error::{Error, Result}; use anyhow::{anyhow, Context}; use celery::error::TaskError; use deadpool_postgres::Client as DbClient; -use electoral_log::client::board_client::ElectoralLogMessage; -use electoral_log::messages::message::Message; +use electoral_log::client::types::ElectoralLogMessage; use immudb_rs::TxMode; use sequent_core::services::keycloak::get_event_realm; use serde::{Deserialize, Serialize}; use std::collections::HashMap; -use tracing::{event, info, instrument}; +use tracing::{info, instrument}; use lapin::{ options::{BasicAckOptions, BasicGetOptions, QueueDeclareOptions}, diff --git a/packages/windmill/src/tasks/generate_report.rs b/packages/windmill/src/tasks/generate_report.rs index 450dac95b92..63df028f49e 100644 --- a/packages/windmill/src/tasks/generate_report.rs +++ b/packages/windmill/src/tasks/generate_report.rs @@ -133,6 +133,7 @@ pub async fn generate_report( .await?; }; } + info!("To generate report type: {report_type_str}"); match ReportType::from_str(&report_type_str) { Ok(ReportType::OVCS_EVENTS) => { let report = OVCSEventsTemplate::new(ids); diff --git a/packages/windmill/src/types/resources.rs b/packages/windmill/src/types/resources.rs index a0e47cfe287..aa25032c343 100644 --- a/packages/windmill/src/types/resources.rs +++ b/packages/windmill/src/types/resources.rs @@ -8,31 +8,31 @@ use immudb_rs::{sql_value::Value, Client, NamedParam, Row, SqlValue}; use serde::{Deserialize, Serialize}; use strum_macros::{Display, EnumString}; -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, Default)] pub struct Aggregate { pub count: i64, } -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, Default)] pub struct TotalAggregate { pub aggregate: Aggregate, } -// Enumeration for the valid order directions -#[derive(Debug, Deserialize, EnumString, Display)] -#[serde(rename_all = "lowercase")] -#[strum(serialize_all = "lowercase")] -pub enum OrderDirection { - Asc, - Desc, -} - #[derive(Serialize, Deserialize, Debug)] pub struct DataList { pub items: Vec, pub total: TotalAggregate, } +impl Default for DataList { + fn default() -> Self { + DataList { + items: vec![], + total: TotalAggregate::default(), + } + } +} + impl TryFrom<&Row> for Aggregate { type Error = anyhow::Error;