From 21f3492a0bd465ceded13e05d639ee57e049423a Mon Sep 17 00:00:00 2001 From: Brian Donovan <1938+eventualbuddha@users.noreply.github.com> Date: Tue, 16 Apr 2024 15:28:26 -0700 Subject: [PATCH] Feat/customize mailing address (#104) * feat: use custom mailing address The address is still hardcoded, but this pipes the data through the system. The next step is to provide the address in the JX UI. * feat: require a mailing label for elections * fix: require jurisdiction code to match * fix: remove return address * style: tidy up imports --- apps/cacvote-jx-terminal/backend/src/app.rs | 25 ++-- apps/cacvote-jx-terminal/backend/src/db.rs | 1 + .../frontend/public/styles.css | 9 ++ .../frontend/src/pages/elections_page.rs | 118 +++++++++++------- apps/cacvote-mark/backend/src/app.ts | 4 +- .../backend/src/cacvote-server/types.ts | 18 ++- .../cacvote-mark/backend/src/mailing_label.ts | 102 ++++----------- libs/types-rs/src/cacvote/mod.rs | 1 + libs/ui-rs/src/file_button.rs | 1 + 9 files changed, 135 insertions(+), 144 deletions(-) diff --git a/apps/cacvote-jx-terminal/backend/src/app.rs b/apps/cacvote-jx-terminal/backend/src/app.rs index e8949ccfc..e7b009711 100644 --- a/apps/cacvote-jx-terminal/backend/src/app.rs +++ b/apps/cacvote-jx-terminal/backend/src/app.rs @@ -8,7 +8,6 @@ use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::time::Duration; use auth_rs::card_details::CardDetailsWithAuthInfo; -use axum::body::Bytes; use axum::response::sse::{Event, KeepAlive}; use axum::response::Sse; use axum::routing::post; @@ -25,7 +24,6 @@ use tracing::Level; use types_rs::cacvote::{ CreateRegistrationData, Election, Payload, Registration, SessionData, SignedObject, }; -use types_rs::election::ElectionDefinition; use uuid::Uuid; use crate::config::{Config, MAX_REQUEST_SIZE}; @@ -185,7 +183,7 @@ async fn create_election( State(AppState { pool, smartcard, .. }): State, - body: Bytes, + Json(election): Json, ) -> impl IntoResponse { let jurisdiction_code = match smartcard.get_card_details() { Some(card_details) => card_details.card_details.jurisdiction_code(), @@ -198,16 +196,12 @@ async fn create_election( } }; - let election_definition = match ElectionDefinition::try_from(&body[..]) { - Ok(election_definition) => election_definition, - Err(e) => { - tracing::error!("error parsing election definition: {e}"); - return ( - StatusCode::BAD_REQUEST, - Json(json!({ "error": format!("error parsing election definition: {e}") })), - ); - } - }; + if election.jurisdiction_code != jurisdiction_code { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ "error": "jurisdiction_code does not match card details" })), + ); + } let mut connection = match pool.acquire().await { Ok(connection) => connection, @@ -220,10 +214,7 @@ async fn create_election( } }; - let payload = Payload::Election(Election { - jurisdiction_code, - election_definition, - }); + let payload = Payload::Election(election); let serialized_payload = match serde_json::to_vec(&payload) { Ok(serialized_payload) => serialized_payload, Err(e) => { diff --git a/apps/cacvote-jx-terminal/backend/src/db.rs b/apps/cacvote-jx-terminal/backend/src/db.rs index 064dff2e1..08ed036e6 100644 --- a/apps/cacvote-jx-terminal/backend/src/db.rs +++ b/apps/cacvote-jx-terminal/backend/src/db.rs @@ -567,6 +567,7 @@ mod tests { let election_payload = cacvote::Payload::Election(cacvote::Election { jurisdiction_code: jurisdiction_code.clone(), election_definition: election_definition.clone(), + mailing_address: "123 Main St".to_owned(), }); let election_object = cacvote::SignedObject::from_payload( &election_payload, diff --git a/apps/cacvote-jx-terminal/frontend/public/styles.css b/apps/cacvote-jx-terminal/frontend/public/styles.css index aa2e3b85d..04674d9cb 100644 --- a/apps/cacvote-jx-terminal/frontend/public/styles.css +++ b/apps/cacvote-jx-terminal/frontend/public/styles.css @@ -594,6 +594,10 @@ video { width: 100vw; } +.w-20 { + width: 5rem; +} + .table-auto { table-layout: auto; } @@ -810,6 +814,11 @@ video { background-color: rgb(209 213 219 / var(--tw-bg-opacity)); } +.hover\:bg-purple-300:hover { + --tw-bg-opacity: 1; + background-color: rgb(216 180 254 / var(--tw-bg-opacity)); +} + .focus\:border-blue-500:focus { --tw-border-opacity: 1; border-color: rgb(59 130 246 / var(--tw-border-opacity)); diff --git a/apps/cacvote-jx-terminal/frontend/src/pages/elections_page.rs b/apps/cacvote-jx-terminal/frontend/src/pages/elections_page.rs index 411bbfecc..4b189a480 100644 --- a/apps/cacvote-jx-terminal/frontend/src/pages/elections_page.rs +++ b/apps/cacvote-jx-terminal/frontend/src/pages/elections_page.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use dioxus::prelude::*; use dioxus_router::hooks::use_navigator; -use types_rs::cacvote::SessionData; +use types_rs::{cacvote, election}; use ui_rs::FileButton; use crate::{ @@ -14,29 +14,44 @@ use crate::{ pub fn ElectionsPage(cx: Scope) -> Element { let nav = use_navigator(cx); - let session_data = use_shared_state::(cx).unwrap(); + let session_data = use_shared_state::(cx).unwrap(); let session_data = &*session_data.read(); - let elections = match session_data { - SessionData::Authenticated { elections, .. } => Some(elections), - _ => None, + let (elections, jurisdiction_code) = match session_data { + cacvote::SessionData::Authenticated { + elections, + jurisdiction_code, + .. + } => (Some(elections), Some(jurisdiction_code)), + _ => (None, None), }; let is_uploading = use_state(cx, || false); + let mailing_address = use_state(cx, || "".to_owned()); + let upload_election = { - to_owned![is_uploading]; - |election_data: Vec| async move { + to_owned![is_uploading, mailing_address]; + |election_data: Vec, jurisdiction_code: cacvote::JurisdictionCode| async move { is_uploading.set(true); - log::info!( - "election data: {}", - String::from_utf8(election_data.clone()).unwrap() - ); + log::info!("election data: {}", String::from_utf8_lossy(&election_data)); + + let Ok(election_definition) = + election::ElectionDefinition::try_from(election_data.as_slice()) + else { + return None; + }; let url = get_url("/api/elections"); let client = reqwest::Client::new(); - let res = client.post(url).body(election_data).send().await; + let election = cacvote::Election { + election_definition, + jurisdiction_code, + mailing_address: mailing_address.get().clone(), + }; + let res = client.post(url).json(&election).send().await; is_uploading.set(false); + mailing_address.set("".to_owned()); Some(res) } @@ -45,7 +60,7 @@ pub fn ElectionsPage(cx: Scope) -> Element { use_effect(cx, (session_data,), |(session_data,)| { to_owned![nav, session_data]; async move { - if matches!(session_data, SessionData::Unauthenticated { .. }) { + if matches!(session_data, cacvote::SessionData::Unauthenticated { .. }) { nav.push(Route::MachineLockedPage); } } @@ -76,47 +91,60 @@ pub fn ElectionsPage(cx: Scope) -> Element { ) } }, - FileButton { - class: "mt-4", - onfile: move |file_engine: Arc| { - cx.spawn({ - to_owned![upload_election, file_engine]; - async move { - if let Some(election_data) = read_file_as_bytes(file_engine).await { - match upload_election(election_data).await { - Some(Ok(response)) => { - if !response.status().is_success() { + h2 { class: "text-xl font-bold mt-8", "New Election" } + textarea { + class: "mt-4 w-30 p-2 border block", + rows: 3, + value: mailing_address.get().as_str(), + oninput: move |e| { + mailing_address.set(e.inner().value.clone()); + }, + placeholder: "Mailing Address", + }, + if let Some(jurisdiction_code) = jurisdiction_code.cloned() { + rsx!(FileButton { + class: "mt-4", + disabled: mailing_address.get().chars().all(char::is_whitespace), + onfile: move |file_engine: Arc| { + cx.spawn({ + to_owned![upload_election, file_engine, jurisdiction_code]; + async move { + if let Some(election_data) = read_file_as_bytes(file_engine).await { + match upload_election(election_data, jurisdiction_code).await { + Some(Ok(response)) => { + if !response.status().is_success() { + web_sys::window() + .unwrap() + .alert_with_message( + &format!( + "Failed to upload election: {:?}", + response.text().await.unwrap_or("unknown error".to_owned()), + ), + ) + .unwrap(); + return; + } + log::info!("Election uploaded successfully"); + } + Some(Err(err)) => { + log::error!("Failed to upload election: {err}"); web_sys::window() .unwrap() .alert_with_message( - &format!( - "Failed to upload election: {:?}", - response.text().await.unwrap_or("unknown error".to_owned()), - ), + &format!("Failed to upload election: {err}"), ) .unwrap(); - return; } - log::info!("Election uploaded successfully"); - } - Some(Err(err)) => { - log::error!("Failed to upload election: {err}"); - web_sys::window() - .unwrap() - .alert_with_message( - &format!("Failed to upload election: {err}"), - ) - .unwrap(); - } - None => { - log::error!("Invalid election data"); + None => { + log::error!("Invalid election data"); + } } } } - } - }); - }, - "Import Election" + }); + }, + "Import Election" + }) } } ) diff --git a/apps/cacvote-mark/backend/src/app.ts b/apps/cacvote-mark/backend/src/app.ts index ed9f55a24..0082f9cb3 100644 --- a/apps/cacvote-mark/backend/src/app.ts +++ b/apps/cacvote-mark/backend/src/app.ts @@ -301,7 +301,9 @@ function buildApi({ ballotMarkingMode: 'machine', }); - const pdf = await mailingLabel.buildPdf(); + const pdf = await mailingLabel.buildPdf({ + mailingAddress: election.getMailingAddress(), + }); execFileSync( 'lpr', diff --git a/apps/cacvote-mark/backend/src/cacvote-server/types.ts b/apps/cacvote-mark/backend/src/cacvote-server/types.ts index 780fde7db..882f27226 100644 --- a/apps/cacvote-mark/backend/src/cacvote-server/types.ts +++ b/apps/cacvote-mark/backend/src/cacvote-server/types.ts @@ -129,7 +129,8 @@ export const JournalEntrySchema: z.ZodSchema = export class Election { constructor( private readonly jurisdictionCode: JurisdictionCode, - private readonly electionDefinition: ElectionDefinition + private readonly electionDefinition: ElectionDefinition, + private readonly mailingAddress: string ) {} getJurisdictionCode(): JurisdictionCode { @@ -140,10 +141,15 @@ export class Election { return this.electionDefinition; } + getMailingAddress(): string { + return this.mailingAddress; + } + toJSON(): unknown { return { jurisdictionCode: this.jurisdictionCode, electionDefinition: this.electionDefinition, + mailingAddress: this.mailingAddress, }; } } @@ -154,11 +160,13 @@ const ElectionStructSchema = z.object({ .string() .transform((s) => Buffer.from(s, 'base64').toString('utf-8')) .transform((s) => safeParseElectionDefinition(s).unsafeUnwrap()), + mailingAddress: z.string(), }); export const ElectionSchema: z.ZodSchema = ElectionStructSchema.transform( - (o) => new Election(o.jurisdictionCode, o.electionDefinition) + (o) => + new Election(o.jurisdictionCode, o.electionDefinition, o.mailingAddress) ) as unknown as z.ZodSchema; export class RegistrationRequest { @@ -405,7 +413,11 @@ export const PayloadSchema: z.ZodSchema = z switch (o.objectType) { case ElectionObjectType: { return Payload.Election( - new Election(o.jurisdictionCode, o.electionDefinition) + new Election( + o.jurisdictionCode, + o.electionDefinition, + o.mailingAddress + ) ); } diff --git a/apps/cacvote-mark/backend/src/mailing_label.ts b/apps/cacvote-mark/backend/src/mailing_label.ts index 8ada84586..669feb531 100644 --- a/apps/cacvote-mark/backend/src/mailing_label.ts +++ b/apps/cacvote-mark/backend/src/mailing_label.ts @@ -72,11 +72,11 @@ function trackingNumberBarcode({ return g({}, ...lines); } -export function buildSvg2(): string { - return xml({}); -} - -export function buildSvg(): string { +export function buildSvg({ + mailingAddress, +}: { + mailingAddress: string; +}): string { const padding = { x: 5.76, y: 12.48, @@ -88,6 +88,8 @@ export function buildSvg(): string { const thickBorderSize = 4; const mediumBorderSize = 3; + const mailingAddressLines = mailingAddress.split('\n').map((l) => l.trim()); + return xml( svg( SIZE_POINTS, @@ -223,50 +225,6 @@ export function buildSvg(): string { ) ), - // Return Address - offset( - { x: 12, y: 84 + 35 }, - svg( - { width: inner.width, height: 96 }, - text('Jane Doe', { - x: 0, - y: 0, - 'dominant-baseline': 'hanging', - 'font-size': 10, - 'font-family': 'open-sans, sans-serif', - fill: 'black', - style: 'text-transform: uppercase;', - }), - text('Example Military Base', { - x: 0, - y: 11, - 'dominant-baseline': 'hanging', - 'font-size': 10, - 'font-family': 'open-sans, sans-serif', - fill: 'black', - style: 'text-transform: uppercase;', - }), - text('1234 Main St', { - x: 0, - y: 22, - 'dominant-baseline': 'hanging', - 'font-size': 10, - 'font-family': 'open-sans, sans-serif', - fill: 'black', - style: 'text-transform: uppercase;', - }), - text('Anytown, CA 95959', { - x: 0, - y: 33, - 'dominant-baseline': 'hanging', - 'font-size': 10, - 'font-family': 'open-sans, sans-serif', - fill: 'black', - style: 'text-transform: uppercase;', - }) - ) - ), - // Shipping Address offset( { x: 12, y: 84 + 35 + 39 + 13 + 55 }, @@ -290,33 +248,17 @@ export function buildSvg(): string { fill: 'black', style: 'text-transform: uppercase;', }), - text('Ballot Receiving Center', { - x: 55, - y: 0, - 'dominant-baseline': 'hanging', - 'font-size': 14, - 'font-family': 'open-sans, sans-serif', - fill: 'black', - style: 'text-transform: uppercase;', - }), - text('1234 Main St', { - x: 55, - y: 16, - 'dominant-baseline': 'hanging', - 'font-size': 14, - 'font-family': 'open-sans, sans-serif', - fill: 'black', - style: 'text-transform: uppercase;', - }), - text('Anytown, CA 95959', { - x: 55, - y: 32, - 'dominant-baseline': 'hanging', - 'font-size': 14, - 'font-family': 'open-sans, sans-serif', - fill: 'black', - style: 'text-transform: uppercase;', - }) + ...mailingAddressLines.map((l, i) => + text(l, { + x: 55, + y: i * 16, + 'dominant-baseline': 'hanging', + 'font-size': 14, + 'font-family': 'open-sans, sans-serif', + fill: 'black', + style: 'text-transform: uppercase;', + }) + ) ) ), @@ -361,8 +303,12 @@ export function buildSvg(): string { ).toString(); } -export async function buildPdf(): Promise { - const content = buildSvg(); +export async function buildPdf({ + mailingAddress, +}: { + mailingAddress: string; +}): Promise { + const content = buildSvg({ mailingAddress }); const userDataDirTemp = dirSync({ unsafeCleanup: true }); const browser = await puppeteer.launch({ executablePath: '/usr/bin/chromium', diff --git a/libs/types-rs/src/cacvote/mod.rs b/libs/types-rs/src/cacvote/mod.rs index a1ccb7651..c04985b5a 100644 --- a/libs/types-rs/src/cacvote/mod.rs +++ b/libs/types-rs/src/cacvote/mod.rs @@ -478,6 +478,7 @@ impl Registration { pub struct Election { pub jurisdiction_code: JurisdictionCode, pub election_definition: ElectionDefinition, + pub mailing_address: String, } impl JurisdictionScoped for Election { diff --git a/libs/ui-rs/src/file_button.rs b/libs/ui-rs/src/file_button.rs index 21505368d..aa1879383 100644 --- a/libs/ui-rs/src/file_button.rs +++ b/libs/ui-rs/src/file_button.rs @@ -74,6 +74,7 @@ pub fn FileButton<'a>(cx: Scope<'a, Props<'a>>) -> Element { } Button { class: cx.props.class.unwrap_or(""), + disabled: cx.props.disabled.unwrap_or(false), &cx.props.children onclick: { to_owned![id];