From 44b526d222884e746b443d3b238a87676f4fc9fc Mon Sep 17 00:00:00 2001 From: Brian Donovan <1938+eventualbuddha@users.noreply.github.com> Date: Tue, 2 Apr 2024 08:57:05 -0700 Subject: [PATCH] feat(jx): re-add registrations (#99) --- apps/cacvote-jx-terminal/backend/src/app.rs | 124 ++++++- apps/cacvote-jx-terminal/backend/src/db.rs | 329 +++++++++++++++++- .../fixtures/electionFamousNames2021.json | 2 +- .../frontend/src/components/mod.rs | 2 + .../frontend/src/pages/voters_page.rs | 196 ++++++++++- libs/types-rs/src/cacvote/mod.rs | 254 +++++++++++++- 6 files changed, 878 insertions(+), 29 deletions(-) diff --git a/apps/cacvote-jx-terminal/backend/src/app.rs b/apps/cacvote-jx-terminal/backend/src/app.rs index 341cb251e0..64641e5b59 100644 --- a/apps/cacvote-jx-terminal/backend/src/app.rs +++ b/apps/cacvote-jx-terminal/backend/src/app.rs @@ -22,7 +22,9 @@ use tokio_stream::StreamExt; use tower_http::services::{ServeDir, ServeFile}; use tower_http::trace::TraceLayer; use tracing::Level; -use types_rs::cacvote::{Election, Payload, SessionData, SignedObject}; +use types_rs::cacvote::{ + CreateRegistrationData, Election, Payload, Registration, SessionData, SignedObject, +}; use types_rs::election::ElectionDefinition; use uuid::Uuid; @@ -70,12 +72,16 @@ pub(crate) fn setup(pool: PgPool, config: Config, smartcard: smartcard::DynSmart if card_details.jurisdiction_code() == jurisdiction_code => { let elections = db::get_elections(&mut connection).await.unwrap(); + let pending_registration_requests = + db::get_pending_registration_requests(&mut connection) + .await + .unwrap(); + let registrations = db::get_registrations(&mut connection).await.unwrap(); SessionData::Authenticated { jurisdiction_code: jurisdiction_code.clone(), - elections: elections - .into_iter() - .map(|e| e.election_definition) - .collect(), + elections, + pending_registration_requests, + registrations, } } Some(_) => SessionData::Unauthenticated { @@ -97,6 +103,7 @@ pub(crate) fn setup(pool: PgPool, config: Config, smartcard: smartcard::DynSmart .route("/api/status-stream", get(get_status_stream)) .route("/api/elections", get(get_elections)) .route("/api/elections", post(create_election)) + .route("/api/registrations", post(create_registration)) .layer(DefaultBodyLimit::max(MAX_REQUEST_SIZE)) .layer(TraceLayer::new_for_http()) .with_state(AppState { @@ -268,3 +275,110 @@ async fn create_election( (StatusCode::CREATED, Json(json!({ "id": signed_object.id }))) } + +async fn create_registration( + State(AppState { + pool, smartcard, .. + }): State, + Json(CreateRegistrationData { + registration_request_id, + election_id, + ballot_style_id, + precinct_id, + }): Json, +) -> impl IntoResponse { + let jurisdiction_code = match smartcard.get_card_details() { + Some(card_details) => card_details.card_details.jurisdiction_code(), + None => { + tracing::error!("no card details found"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "no card details found" })), + ); + } + }; + + let mut connection = match pool.acquire().await { + Ok(connection) => connection, + Err(e) => { + tracing::error!("error getting database connection: {e}"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "error getting database connection" })), + ); + } + }; + + let registration_request = + match db::get_registration_request(&mut connection, registration_request_id).await { + Ok(registration_request) => registration_request, + Err(e) => { + tracing::error!("error getting registration request from database: {e}"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "error getting registration request from database" })), + ); + } + }; + + let payload = Payload::Registration(Registration { + jurisdiction_code, + common_access_card_id: registration_request.common_access_card_id, + registration_request_object_id: registration_request_id, + election_object_id: election_id, + ballot_style_id, + precinct_id, + }); + let serialized_payload = match serde_json::to_vec(&payload) { + Ok(serialized_payload) => serialized_payload, + Err(e) => { + tracing::error!("error serializing payload: {e}"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "error serializing payload" })), + ); + } + }; + + let signed = match smartcard.sign(&serialized_payload, None) { + Ok(signed) => signed, + Err(e) => { + tracing::error!("error signing payload: {e}"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "error signing payload" })), + ); + } + }; + let certificates: Vec = match signed + .cert_stack + .iter() + .map(|cert| cert.to_pem()) + .collect::, _>>() + { + Ok(certificates) => certificates.concat(), + Err(e) => { + tracing::error!("error converting certificates to PEM: {e}"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "error converting certificates to PEM" })), + ); + } + }; + let signed_object = SignedObject { + id: Uuid::new_v4(), + payload: serialized_payload, + certificates, + signature: signed.data, + }; + + if let Err(e) = db::add_object(&mut connection, &signed_object).await { + tracing::error!("error adding object to database: {e}"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "error adding object to database" })), + ); + } + + (StatusCode::CREATED, Json(json!({ "id": signed_object.id }))) +} diff --git a/apps/cacvote-jx-terminal/backend/src/db.rs b/apps/cacvote-jx-terminal/backend/src/db.rs index 5c43fe7bc2..2bbc8abfe4 100644 --- a/apps/cacvote-jx-terminal/backend/src/db.rs +++ b/apps/cacvote-jx-terminal/backend/src/db.rs @@ -14,7 +14,7 @@ use color_eyre::eyre::bail; use sqlx::postgres::PgPoolOptions; use sqlx::{Connection, PgPool}; use tracing::Level; -use types_rs::cacvote::{JournalEntry, JurisdictionCode, SignedObject}; +use types_rs::cacvote; use uuid::Uuid; use crate::config::Config; @@ -37,9 +37,9 @@ pub(crate) async fn setup(config: &Config) -> color_eyre::Result { pub(crate) async fn get_elections( connection: &mut sqlx::PgConnection, -) -> color_eyre::eyre::Result> { +) -> color_eyre::eyre::Result> { let objects = sqlx::query_as!( - SignedObject, + cacvote::SignedObject, r#" SELECT id, @@ -68,18 +68,197 @@ pub(crate) async fn get_elections( } }; - if let types_rs::cacvote::Payload::Election(election) = payload { - elections.push(election); + if let cacvote::Payload::Election(election) = payload { + elections.push(cacvote::ElectionPresenter::new(object.id, election)); } } Ok(elections) } +#[tracing::instrument(skip(connection))] +pub async fn get_pending_registration_requests( + connection: &mut sqlx::PgConnection, +) -> color_eyre::eyre::Result> { + let records = sqlx::query!( + r#" + SELECT + rr.id, + rr.payload, + rr.certificates, + rr.signature, + rr.created_at + FROM + objects AS rr + WHERE + rr.object_type = $1 + AND + NOT EXISTS ( + SELECT 1 + FROM objects AS r + WHERE r.object_type = $2 + AND rr.id = (convert_from(r.payload, 'UTF8')::jsonb ->> $3)::uuid + ) + ORDER BY rr.created_at DESC + "#, + cacvote::Payload::registration_request_object_type(), + cacvote::Payload::registration_object_type(), + cacvote::Registration::registration_request_object_id_field_name(), + ) + .fetch_all(connection) + .await?; + + let mut registration_requests = Vec::new(); + + for record in records { + let object = cacvote::SignedObject { + id: record.id, + payload: record.payload, + certificates: record.certificates, + signature: record.signature, + }; + + if let cacvote::Payload::RegistrationRequest(registration_request) = + object.try_to_inner()? + { + registration_requests.push(cacvote::RegistrationRequestPresenter::new( + object.id, + registration_request, + record.created_at, + )); + } + } + + Ok(registration_requests) +} + +#[tracing::instrument(skip(connection))] +pub async fn get_registrations( + connection: &mut sqlx::PgConnection, +) -> color_eyre::eyre::Result> { + let records = sqlx::query!( + r#" + SELECT + r.id AS registration_id, + r.payload AS registration_payload, + r.certificates AS registration_certificates, + r.signature AS registration_signature, + e.id AS election_id, + e.payload AS election_payload, + e.certificates AS election_certificates, + e.signature AS election_signature, + rr.id AS registration_request_id, + rr.payload AS registration_request_payload, + rr.certificates AS registration_request_certificates, + rr.signature AS registration_request_signature, + r.created_at AS created_at, + r.server_synced_at IS NOT NULL AS "is_synced!: bool" + FROM objects AS r + INNER JOIN objects AS e + ON (convert_from(r.payload, 'UTF8')::jsonb ->> $1)::uuid = e.id + INNER JOIN objects AS rr + ON (convert_from(r.payload, 'UTF8')::jsonb ->> $2)::uuid = rr.id + WHERE e.object_type = $3 + AND r.object_type = $4 + ORDER BY r.created_at DESC + "#, + cacvote::Registration::election_object_id_field_name(), + cacvote::Registration::registration_request_object_id_field_name(), + cacvote::Payload::election_object_type(), + cacvote::Payload::registration_object_type(), + ) + .fetch_all(connection) + .await?; + + let mut registrations = Vec::new(); + + for record in records { + let registration_object = cacvote::SignedObject { + id: record.registration_id, + payload: record.registration_payload, + certificates: record.registration_certificates, + signature: record.registration_signature, + }; + let election_object = cacvote::SignedObject { + id: record.election_id, + payload: record.election_payload, + certificates: record.election_certificates, + signature: record.election_signature, + }; + let registration_request_object = cacvote::SignedObject { + id: record.registration_request_id, + payload: record.registration_request_payload, + certificates: record.registration_request_certificates, + signature: record.registration_request_signature, + }; + + if let cacvote::Payload::Registration(registration) = registration_object.try_to_inner()? { + if let cacvote::Payload::Election(election_payload) = election_object.try_to_inner()? { + if let cacvote::Payload::RegistrationRequest(registration_request) = + registration_request_object.try_to_inner()? + { + let display_name = registration_request.display_name(); + let election_title = election_payload.election.title.clone(); + let election_hash = election_payload.election_hash.clone(); + let created_at = record.created_at; + let is_synced = record.is_synced; + registrations.push(cacvote::RegistrationPresenter::new( + registration_object.id, + display_name, + election_title, + election_hash, + registration, + created_at, + is_synced, + )); + } + } + } + } + + Ok(registrations) +} + +#[tracing::instrument(skip(connection))] +pub async fn get_registration_request( + connection: &mut sqlx::PgConnection, + id: Uuid, +) -> color_eyre::Result { + if let cacvote::Payload::RegistrationRequest(registration_request) = + get_object(connection, id).await?.try_to_inner()? + { + Ok(registration_request) + } else { + bail!("Object is not a registration request") + } +} + +#[tracing::instrument(skip(connection))] +pub async fn get_object( + connection: &mut sqlx::PgConnection, + id: Uuid, +) -> color_eyre::Result { + Ok(sqlx::query_as!( + cacvote::SignedObject, + r#" + SELECT + id, + payload, + certificates, + signature + FROM objects + WHERE id = $1 + "#, + id + ) + .fetch_one(connection) + .await?) +} + #[tracing::instrument(skip(connection, object))] pub async fn add_object_from_server( connection: &mut sqlx::PgConnection, - object: &SignedObject, + object: &cacvote::SignedObject, ) -> color_eyre::Result { if !object.verify()? { bail!("Unable to verify signature/certificates") @@ -114,7 +293,7 @@ pub async fn add_object_from_server( #[tracing::instrument(skip(connection, object))] pub async fn add_object( connection: &mut sqlx::PgConnection, - object: &SignedObject, + object: &cacvote::SignedObject, ) -> color_eyre::Result { if !object.verify()? { bail!("Unable to verify signature/certificates") @@ -149,7 +328,7 @@ pub async fn add_object( #[tracing::instrument(skip(connection, entries))] pub(crate) async fn add_journal_entries( connection: &mut sqlx::PgConnection, - entries: Vec, + entries: Vec, ) -> color_eyre::eyre::Result<()> { let mut txn = connection.begin().await?; for entry in entries { @@ -175,14 +354,14 @@ pub(crate) async fn add_journal_entries( pub(crate) async fn get_latest_journal_entry( connection: &mut sqlx::PgConnection, -) -> color_eyre::eyre::Result> { +) -> color_eyre::eyre::Result> { Ok(sqlx::query_as!( - JournalEntry, + cacvote::JournalEntry, r#" SELECT id, object_id, - jurisdiction as "jurisdiction_code: JurisdictionCode", + jurisdiction as "jurisdiction_code: cacvote::JurisdictionCode", object_type, action, created_at @@ -197,9 +376,9 @@ pub(crate) async fn get_latest_journal_entry( pub(crate) async fn get_unsynced_objects( executor: &mut sqlx::PgConnection, -) -> color_eyre::eyre::Result> { +) -> color_eyre::eyre::Result> { Ok(sqlx::query_as!( - SignedObject, + cacvote::SignedObject, r#" SELECT id, @@ -234,23 +413,139 @@ pub(crate) async fn mark_object_synced( pub(crate) async fn get_journal_entries_for_objects_to_pull( executor: &mut sqlx::PgConnection, -) -> color_eyre::eyre::Result> { +) -> color_eyre::eyre::Result> { Ok(sqlx::query_as!( - JournalEntry, + cacvote::JournalEntry, r#" SELECT id, object_id, - jurisdiction as "jurisdiction_code: JurisdictionCode", + jurisdiction as "jurisdiction_code: cacvote::JurisdictionCode", object_type, action, created_at FROM journal_entries WHERE object_id IS NOT NULL - AND object_type IN ('RegistrationRequest') + AND object_type IN ($1) AND object_id NOT IN (SELECT id FROM objects) "#, + cacvote::Payload::registration_request_object_type(), ) .fetch_all(&mut *executor) .await?) } + +#[cfg(test)] +mod tests { + use openssl::{ + pkey::{PKey, Private, Public}, + x509::X509, + }; + use types_rs::{cacvote::JurisdictionCode, election::ElectionDefinition}; + + use super::*; + + fn load_keypair() -> color_eyre::Result<(X509, PKey, PKey)> { + // uses the dev VxAdmin keypair because it has the Jurisdiction field + let private_key_pem = + include_bytes!("../../../../libs/auth/certs/dev/vx-admin-private-key.pem"); + let private_key = PKey::private_key_from_pem(private_key_pem)?; + let certificates = + include_bytes!("../../../../libs/auth/certs/dev/vx-admin-cert-authority-cert.pem") + .to_vec(); + let x509 = X509::from_pem(&certificates)?; + let public_key = x509.public_key()?; + Ok((x509, public_key, private_key)) + } + + fn load_election_definition() -> color_eyre::Result { + Ok(ElectionDefinition::try_from( + &include_bytes!("../tests/fixtures/electionFamousNames2021.json")[..], + )?) + } + + #[sqlx::test(migrations = "db/migrations")] + async fn test_pending_registration_requests(pool: sqlx::PgPool) -> color_eyre::Result<()> { + let (certificates, _, private_key) = load_keypair()?; + let election_definition = load_election_definition()?; + let mut connection = &mut pool.acquire().await?; + let jurisdiction_code = JurisdictionCode::try_from("st.test-jurisdiction").unwrap(); + + let election_payload = cacvote::Payload::Election(cacvote::Election { + jurisdiction_code: jurisdiction_code.clone(), + election_definition: election_definition.clone(), + }); + let election_object = cacvote::SignedObject::from_payload( + &election_payload, + vec![certificates.clone()], + &private_key, + )?; + + add_object_from_server(&mut connection, &election_object).await?; + + let pending_registration_requests = + get_pending_registration_requests(&mut connection).await?; + + assert!( + pending_registration_requests.is_empty(), + "Expected no pending registration requests, got {pending_registration_requests:?}", + ); + + let registration_request_payload = + cacvote::Payload::RegistrationRequest(cacvote::RegistrationRequest { + jurisdiction_code: jurisdiction_code.clone(), + common_access_card_id: "0123456789".to_owned(), + family_name: "Smith".to_owned(), + given_name: "John".to_owned(), + }); + let registration_request_object = cacvote::SignedObject::from_payload( + ®istration_request_payload, + vec![certificates.clone()], + &private_key, + )?; + + add_object_from_server(&mut connection, ®istration_request_object).await?; + + let pending_registration_requests = + get_pending_registration_requests(&mut connection).await?; + + match pending_registration_requests.as_slice() { + [registration_request] => { + assert_eq!(registration_request.common_access_card_id, "0123456789"); + assert_eq!(registration_request.given_name, "John"); + assert_eq!(registration_request.family_name, "Smith"); + assert_eq!(registration_request.jurisdiction_code, jurisdiction_code); + } + _ => panic!("Expected one registration request, got {pending_registration_requests:?}"), + } + + let ballot_style_id = election_definition.election.ballot_styles[0].id.clone(); + let precinct_id = election_definition.election.precincts[0].id.clone(); + + let registration_payload = cacvote::Payload::Registration(cacvote::Registration { + jurisdiction_code: jurisdiction_code.clone(), + common_access_card_id: "0123456789".to_owned(), + registration_request_object_id: registration_request_object.id, + election_object_id: election_object.id, + ballot_style_id, + precinct_id, + }); + let registration_object = cacvote::SignedObject::from_payload( + ®istration_payload, + vec![certificates.clone()], + &private_key, + )?; + + add_object_from_server(&mut connection, ®istration_object).await?; + + let pending_registration_requests = + get_pending_registration_requests(&mut connection).await?; + + assert!( + pending_registration_requests.is_empty(), + "Expected no pending registration requests, got {pending_registration_requests:?}", + ); + + Ok(()) + } +} diff --git a/apps/cacvote-jx-terminal/backend/tests/fixtures/electionFamousNames2021.json b/apps/cacvote-jx-terminal/backend/tests/fixtures/electionFamousNames2021.json index 3e7a46c1fe..3adb0e7805 100755 --- a/apps/cacvote-jx-terminal/backend/tests/fixtures/electionFamousNames2021.json +++ b/apps/cacvote-jx-terminal/backend/tests/fixtures/electionFamousNames2021.json @@ -5,7 +5,7 @@ "id": "franklin", "name": "Franklin County" }, - "date": "2021-06-06T00:00:00-10:00", + "date": "2021-06-06", "parties": [ { "id": "0", diff --git a/apps/cacvote-jx-terminal/frontend/src/components/mod.rs b/apps/cacvote-jx-terminal/frontend/src/components/mod.rs index ce0dd77762..9fa985affc 100644 --- a/apps/cacvote-jx-terminal/frontend/src/components/mod.rs +++ b/apps/cacvote-jx-terminal/frontend/src/components/mod.rs @@ -1 +1,3 @@ mod election_configuration_cell; + +pub use election_configuration_cell::ElectionConfigurationCell; diff --git a/apps/cacvote-jx-terminal/frontend/src/pages/voters_page.rs b/apps/cacvote-jx-terminal/frontend/src/pages/voters_page.rs index 2e9befa6ef..0d0a31633e 100644 --- a/apps/cacvote-jx-terminal/frontend/src/pages/voters_page.rs +++ b/apps/cacvote-jx-terminal/frontend/src/pages/voters_page.rs @@ -1,11 +1,203 @@ use dioxus::prelude::*; +use types_rs::cacvote::{self, SessionData}; +use ui_rs::DateOrDateTimeCell; + +use crate::{components::ElectionConfigurationCell, util::url::get_url}; pub fn VotersPage(cx: Scope) -> Element { + let session_data = use_shared_state::(cx).unwrap(); + let session_data = &*session_data.read(); + let (elections, pending_registration_requests, registrations) = match session_data { + SessionData::Authenticated { + elections, + pending_registration_requests, + registrations, + .. + } => (elections, pending_registration_requests, registrations), + SessionData::Unauthenticated { .. } => { + return render!(h1 { class: "text-2xl font-bold", "Please log in to view this page" }) + } + }; + render!( h1 { class: "text-2xl font-bold mb-4", "Pending Registrations" } - rsx!("No pending registrations") + if pending_registration_requests.is_empty() { + rsx!("No pending registrations") + } else { + rsx!(PendingRegistrationsTable { + elections: elections.clone(), + pending_registration_requests: pending_registration_requests.clone(), + }) + } h1 { class: "text-2xl font-bold mt-4 mb-4", "Registrations" } - rsx!("No registrations") + if registrations.is_empty() { + rsx!("No registrations") + } else { + rsx!(RegistrationsTable { + registrations: registrations.clone(), + }) + } + ) +} + +#[derive(PartialEq, Props)] +struct PendingRegistrationsTableProps { + elections: Vec, + pending_registration_requests: Vec, +} + +fn PendingRegistrationsTable(cx: Scope) -> Element { + let elections = &cx.props.elections; + let pending_registration_requests = &cx.props.pending_registration_requests; + + // let is_linking_registration_request_with_election = use_state(cx, || false); + + let link_voter_registration_request_and_election = { + // TODO: make this work + // to_owned![is_linking_registration_request_with_election]; + |create_registration_data: cacvote::CreateRegistrationData| async move { + // is_linking_registration_request_with_election.set(true); + + let url = get_url("/api/registrations"); + let client = reqwest::Client::new(); + client + .post(url) + .json(&create_registration_data) + .send() + .await + // is_linking_registration_request_with_election.set(false); + } + }; + + render!( + div { + rsx!( + table { class: "table-auto w-full", + thead { + tr { + th { class: "px-4 py-2 text-left", "Voter Name" } + th { class: "px-4 py-2 text-left", "Voter CAC ID" } + th { class: "px-4 py-2 text-left", "Election Configuration" } + th { class: "px-4 py-2 text-left", "Created At" } + } + } + tbody { + for registration_request_presenter in pending_registration_requests { + tr { + td { class: "border px-4 py-2", "{registration_request_presenter.display_name()}" } + td { class: "border px-4 py-2", "{registration_request_presenter.common_access_card_id}" } + td { + class: "border px-4 py-2 justify-center", + select { + class: "dark:bg-gray-800 dark:text-white dark:border-gray-600 border-2 rounded-md p-2 focus:outline-none focus:border-blue-500", + oninput: move |event| { + let create_registration_data = serde_json::from_str::(event.inner().value.as_str()).expect("parse succeeded"); + cx.spawn({ + to_owned![link_voter_registration_request_and_election, create_registration_data]; + async move { + log::info!("linking registration request: {create_registration_data:?}"); + match link_voter_registration_request_and_election(create_registration_data).await { + Ok(response) => { + if !response.status().is_success() { + web_sys::window() + .unwrap() + .alert_with_message(format!("Error linking registration request to election: {}", response.status().as_str()).as_str()) + .unwrap(); + return; + } + + log::info!("linked registration request to election: {:?}", response); + } + Err(err) => { + web_sys::window() + .unwrap() + .alert_with_message(format!("Error linking registration request to election: {err:?}").as_str()) + .unwrap(); + } + } + } + }) + }, + option { + value: "", + disabled: true, + "Select election configuration" + } + for election_presenter in elections.iter() { + optgroup { + label: "{election_presenter.election.title} ({election_presenter.election_hash.to_partial()})", + for ballot_style in election_presenter.election.ballot_styles.iter() { + for precinct_id in ballot_style.precincts.iter() { + { + let create_registration_data = cacvote::CreateRegistrationData { + election_id: election_presenter.id, + registration_request_id: registration_request_presenter.id, + ballot_style_id: ballot_style.id.clone(), + precinct_id: precinct_id.clone(), + }; + let value = serde_json::to_string(&create_registration_data) + .expect("serialization succeeds"); + rsx!( + option { + value: "{value}", + "{ballot_style.id} / {precinct_id}" + } + ) + } + } + } + } + } + } + } + DateOrDateTimeCell { + date_or_datetime: registration_request_presenter.created_at(), + } + } + } + } + } + ) + } + ) +} + +#[derive(Debug, PartialEq, Props)] +struct RegistrationsTableProps { + registrations: Vec, +} + +fn RegistrationsTable(cx: Scope) -> Element { + render!( + table { class: "table-auto w-full", + thead { + tr { + th { class: "px-4 py-2 text-left", "Voter Name" } + th { class: "px-4 py-2 text-left", "Voter CAC ID" } + th { class: "px-4 py-2 text-left", "Election Configuration" } + th { class: "px-4 py-2 text-left", "Synced" } + th { class: "px-4 py-2 text-left", "Created At" } + } + } + tbody { + for registration in cx.props.registrations.iter() { + tr { + td { class: "border px-4 py-2", "{registration.display_name()}" } + td { class: "border px-4 py-2", "{registration.common_access_card_id}" } + ElectionConfigurationCell { + election_title: registration.election_title(), + election_hash: registration.election_hash(), + precinct_id: registration.precinct_id().clone(), + ballot_style_id: registration.ballot_style_id().clone(), + } + td { class: "border px-4 py-2", if registration.is_synced() { "Yes" } else { "No" } } + DateOrDateTimeCell { + date_or_datetime: registration.created_at(), + } + } + } + } + } ) } diff --git a/libs/types-rs/src/cacvote/mod.rs b/libs/types-rs/src/cacvote/mod.rs index 96d7d0b005..d2e3dfa965 100644 --- a/libs/types-rs/src/cacvote/mod.rs +++ b/libs/types-rs/src/cacvote/mod.rs @@ -1,12 +1,15 @@ use std::fmt; +use std::ops::Deref; use std::str::FromStr; use base64_serde::base64_serde_type; use serde::{Deserialize, Serialize}; +use time::OffsetDateTime; use uuid::Uuid; use crate::election::BallotStyleId; use crate::election::ElectionDefinition; +use crate::election::ElectionHash; use crate::election::PrecinctId; base64_serde_type!(Base64Standard, base64::engine::general_purpose::STANDARD); @@ -199,11 +202,29 @@ pub enum Payload { impl Payload { pub fn object_type(&self) -> &'static str { match self { - Self::RegistrationRequest(_) => "RegistrationRequest", - Self::Registration(_) => "Registration", - Self::Election(_) => "Election", + Self::RegistrationRequest(_) => Self::registration_request_object_type(), + Self::Registration(_) => Self::registration_object_type(), + Self::Election(_) => Self::election_object_type(), } } + + pub fn registration_request_object_type() -> &'static str { + // This must match the naming rules of the `serde` attribute in the + // `Payload` enum. + "RegistrationRequest" + } + + pub fn registration_object_type() -> &'static str { + // This must match the naming rules of the `serde` attribute in the + // `Payload` enum. + "Registration" + } + + pub fn election_object_type() -> &'static str { + // This must match the naming rules of the `serde` attribute in the + // `Payload` enum. + "Election" + } } impl JurisdictionScoped for Payload { @@ -357,6 +378,36 @@ impl JurisdictionScoped for RegistrationRequest { } } +impl RegistrationRequest { + pub fn common_access_card_id_field_name() -> &'static str { + // This must match the naming rules of the `serde` attribute in the + // `RegistrationRequest` struct. + "commonAccessCardId" + } + + pub fn jurisdiction_code_field_name() -> &'static str { + // This must match the naming rules of the `serde` attribute in the + // `RegistrationRequest` struct. + "jurisdictionCode" + } + + pub fn given_name_field_name() -> &'static str { + // This must match the naming rules of the `serde` attribute in the + // `RegistrationRequest` struct. + "givenName" + } + + pub fn family_name_field_name() -> &'static str { + // This must match the naming rules of the `serde` attribute in the + // `RegistrationRequest` struct. + "familyName" + } + + pub fn display_name(&self) -> String { + format!("{} {}", self.given_name, self.family_name) + } +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Registration { @@ -374,6 +425,44 @@ impl JurisdictionScoped for Registration { } } +impl Registration { + pub fn common_access_card_id_field_name() -> &'static str { + // This must match the naming rules of the `serde` attribute in the + // `Registration` struct. + "commonAccessCardId" + } + + pub fn jurisdiction_code_field_name() -> &'static str { + // This must match the naming rules of the `serde` attribute in the + // `Registration` struct + "jurisdictionCode" + } + + pub fn registration_request_object_id_field_name() -> &'static str { + // This must match the naming rules of the `serde` attribute in the + // `Registration` struct + "registrationRequestObjectId" + } + + pub fn election_object_id_field_name() -> &'static str { + // This must match the naming rules of the `serde` attribute in the + // `Registration` struct + "electionObjectId" + } + + pub fn ballot_style_id_field_name() -> &'static str { + // This must match the naming rules of the `serde` attribute in the + // `Registration` struct + "ballotStyleId" + } + + pub fn precinct_id_field_name() -> &'static str { + // This must match the naming rules of the `serde` attribute in the + // `Registration` struct + "precinctId" + } +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Election { @@ -387,12 +476,22 @@ impl JurisdictionScoped for Election { } } +impl Deref for Election { + type Target = ElectionDefinition; + + fn deref(&self) -> &Self::Target { + &self.election_definition + } +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum SessionData { Authenticated { jurisdiction_code: JurisdictionCode, - elections: Vec, + elections: Vec, + pending_registration_requests: Vec, + registrations: Vec, }, Unauthenticated { has_smartcard: bool, @@ -406,3 +505,150 @@ impl Default for SessionData { } } } + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateRegistrationData { + pub election_id: Uuid, + pub registration_request_id: Uuid, + pub ballot_style_id: BallotStyleId, + pub precinct_id: PrecinctId, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateElectionData { + pub election_data: String, + pub return_address: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ElectionPresenter { + pub id: Uuid, + election: Election, +} + +impl ElectionPresenter { + pub fn new(id: Uuid, election: Election) -> Self { + Self { id, election } + } +} + +impl Deref for ElectionPresenter { + type Target = Election; + + fn deref(&self) -> &Self::Target { + &self.election + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RegistrationRequestPresenter { + pub id: Uuid, + registration_request: RegistrationRequest, + #[serde(with = "time::serde::iso8601")] + created_at: OffsetDateTime, +} + +impl Deref for RegistrationRequestPresenter { + type Target = RegistrationRequest; + + fn deref(&self) -> &Self::Target { + &self.registration_request + } +} + +impl RegistrationRequestPresenter { + pub fn new( + id: Uuid, + registration_request: RegistrationRequest, + created_at: OffsetDateTime, + ) -> Self { + Self { + id, + registration_request, + created_at, + } + } + + pub fn display_name(&self) -> String { + format!( + "{} {}", + self.registration_request.given_name, self.registration_request.family_name + ) + } + + pub fn created_at(&self) -> OffsetDateTime { + self.created_at + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RegistrationPresenter { + pub id: Uuid, + display_name: String, + election_title: String, + election_hash: ElectionHash, + registration: Registration, + #[serde(with = "time::serde::iso8601")] + created_at: OffsetDateTime, + is_synced: bool, +} + +impl Deref for RegistrationPresenter { + type Target = Registration; + + fn deref(&self) -> &Self::Target { + &self.registration + } +} + +impl RegistrationPresenter { + pub fn new( + id: Uuid, + display_name: String, + election_title: String, + election_hash: ElectionHash, + registration: Registration, + created_at: OffsetDateTime, + is_synced: bool, + ) -> Self { + Self { + id, + display_name, + election_title, + election_hash, + registration, + created_at, + is_synced, + } + } + + pub fn display_name(&self) -> String { + self.display_name.clone() + } + + pub fn election_title(&self) -> String { + self.election_title.clone() + } + + pub fn election_hash(&self) -> ElectionHash { + self.election_hash.clone() + } + + pub fn precinct_id(&self) -> PrecinctId { + self.precinct_id.clone() + } + + pub fn ballot_style_id(&self) -> BallotStyleId { + self.ballot_style_id.clone() + } + + pub fn is_synced(&self) -> bool { + self.is_synced + } + + pub fn created_at(&self) -> OffsetDateTime { + self.created_at + } +}