diff --git a/apps/cacvote-jx-terminal/backend/src/app.rs b/apps/cacvote-jx-terminal/backend/src/app.rs index 64641e5b5..e8949ccfc 100644 --- a/apps/cacvote-jx-terminal/backend/src/app.rs +++ b/apps/cacvote-jx-terminal/backend/src/app.rs @@ -77,11 +77,13 @@ pub(crate) fn setup(pool: PgPool, config: Config, smartcard: smartcard::DynSmart .await .unwrap(); let registrations = db::get_registrations(&mut connection).await.unwrap(); + let cast_ballots = db::get_cast_ballots(&mut connection).await.unwrap(); SessionData::Authenticated { jurisdiction_code: jurisdiction_code.clone(), elections, pending_registration_requests, registrations, + cast_ballots, } } Some(_) => SessionData::Unauthenticated { diff --git a/apps/cacvote-jx-terminal/backend/src/db.rs b/apps/cacvote-jx-terminal/backend/src/db.rs index 2bbc8abfe..064dff2e1 100644 --- a/apps/cacvote-jx-terminal/backend/src/db.rs +++ b/apps/cacvote-jx-terminal/backend/src/db.rs @@ -426,15 +426,108 @@ pub(crate) async fn get_journal_entries_for_objects_to_pull( created_at FROM journal_entries WHERE object_id IS NOT NULL - AND object_type IN ($1) + AND object_type IN ($1, $2) AND object_id NOT IN (SELECT id FROM objects) "#, cacvote::Payload::registration_request_object_type(), + cacvote::Payload::cast_ballot_object_type(), ) .fetch_all(&mut *executor) .await?) } +pub(crate) async fn get_cast_ballots( + executor: &mut sqlx::PgConnection, +) -> color_eyre::Result> { + let records = sqlx::query!( + r#" + SELECT + cb.id AS cast_ballot_id, + cb.payload AS cast_ballot_payload, + cb.certificates AS cast_ballot_certificates, + cb.signature AS cast_ballot_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.id AS registration_id, + r.payload AS registration_payload, + r.certificates AS registration_certificates, + r.signature AS registration_signature, + cb.created_at AS created_at + FROM objects AS cb + -- join on registration request + INNER JOIN objects AS rr + ON (convert_from(cb.payload, 'UTF8')::jsonb ->> $1)::uuid = rr.id + -- join on registration + INNER JOIN objects AS r + ON (convert_from(cb.payload, 'UTF8')::jsonb ->> $2)::uuid = r.id + WHERE rr.object_type = $3 + AND cb.object_type = $4 + AND r.object_type = $5 + ORDER BY cb.created_at DESC + "#, + cacvote::CastBallot::registration_request_object_id_field_name(), + cacvote::CastBallot::registration_object_id_field_name(), + cacvote::Payload::registration_request_object_type(), + cacvote::Payload::cast_ballot_object_type(), + cacvote::Payload::registration_object_type(), + ) + .fetch_all(&mut *executor) + .await?; + + let mut cast_ballots = Vec::new(); + + for record in records { + let cast_ballot_object = cacvote::SignedObject { + id: record.cast_ballot_id, + payload: record.cast_ballot_payload, + certificates: record.cast_ballot_certificates, + signature: record.cast_ballot_signature, + }; + let registration_object = cacvote::SignedObject { + id: record.registration_id, + payload: record.registration_payload, + certificates: record.registration_certificates, + signature: record.registration_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::CastBallot(cast_ballot) = cast_ballot_object.try_to_inner()? { + if let cacvote::Payload::RegistrationRequest(registration_request) = + registration_request_object.try_to_inner()? + { + if let cacvote::Payload::Registration(registration) = + registration_object.try_to_inner()? + { + // TODO: remove this or replace with actual verification status + // we already verify the signature as part of adding the object to the DB, + // so we can assume that the verification status is success + let verification_status = cacvote::VerificationStatus::Success { + common_access_card_id: cast_ballot.common_access_card_id.clone(), + display_name: "test".to_owned(), + }; + let created_at = record.created_at; + cast_ballots.push(cacvote::CastBallotPresenter::new( + cast_ballot, + registration_request, + registration, + verification_status, + created_at, + )); + } + } + } + } + + Ok(cast_ballots) +} + #[cfg(test)] mod tests { use openssl::{ diff --git a/apps/cacvote-jx-terminal/frontend/public/styles.css b/apps/cacvote-jx-terminal/frontend/public/styles.css index edc32f0f8..aa2e3b85d 100644 --- a/apps/cacvote-jx-terminal/frontend/public/styles.css +++ b/apps/cacvote-jx-terminal/frontend/public/styles.css @@ -586,14 +586,14 @@ video { width: 80%; } -.w-screen { - width: 100vw; -} - .w-full { width: 100%; } +.w-screen { + width: 100vw; +} + .table-auto { table-layout: auto; } @@ -622,10 +622,19 @@ video { border-radius: 0.375rem; } +.rounded-l-md { + border-top-left-radius: 0.375rem; + border-bottom-left-radius: 0.375rem; +} + .border { border-width: 1px; } +.border-2 { + border-width: 2px; +} + .bg-gray-200 { --tw-bg-opacity: 1; background-color: rgb(229 231 235 / var(--tw-bg-opacity)); @@ -636,11 +645,36 @@ video { background-color: rgb(209 213 219 / var(--tw-bg-opacity)); } +.bg-gray-400 { + --tw-bg-opacity: 1; + background-color: rgb(156 163 175 / var(--tw-bg-opacity)); +} + +.bg-green-300 { + --tw-bg-opacity: 1; + background-color: rgb(134 239 172 / var(--tw-bg-opacity)); +} + +.bg-orange-300 { + --tw-bg-opacity: 1; + background-color: rgb(253 186 116 / var(--tw-bg-opacity)); +} + .bg-purple-500 { --tw-bg-opacity: 1; background-color: rgb(168 85 247 / var(--tw-bg-opacity)); } +.bg-red-300 { + --tw-bg-opacity: 1; + background-color: rgb(252 165 165 / var(--tw-bg-opacity)); +} + +.bg-yellow-300 { + --tw-bg-opacity: 1; + background-color: rgb(253 224 71 / var(--tw-bg-opacity)); +} + .p-1 { padding: 0.25rem; } @@ -672,6 +706,21 @@ video { padding-bottom: 0.5rem; } +.pe-2 { + -webkit-padding-end: 0.5rem; + padding-inline-end: 0.5rem; +} + +.ps-0 { + -webkit-padding-start: 0px; + padding-inline-start: 0px; +} + +.ps-2 { + -webkit-padding-start: 0.5rem; + padding-inline-start: 0.5rem; +} + .text-left { text-align: left; } @@ -695,34 +744,63 @@ video { line-height: 1.75rem; } -.text-xl { - font-size: 1.25rem; - line-height: 1.75rem; -} - .text-sm { font-size: 0.875rem; line-height: 1.25rem; } +.text-xl { + font-size: 1.25rem; + line-height: 1.75rem; +} + .font-bold { font-weight: 700; } +.font-semibold { + font-weight: 600; +} + .italic { font-style: italic; } +.text-gray-200 { + --tw-text-opacity: 1; + color: rgb(229 231 235 / var(--tw-text-opacity)); +} + .text-gray-400 { --tw-text-opacity: 1; color: rgb(156 163 175 / var(--tw-text-opacity)); } +.text-green-800 { + --tw-text-opacity: 1; + color: rgb(22 101 52 / var(--tw-text-opacity)); +} + +.text-orange-800 { + --tw-text-opacity: 1; + color: rgb(154 52 18 / var(--tw-text-opacity)); +} + +.text-red-800 { + --tw-text-opacity: 1; + color: rgb(153 27 27 / var(--tw-text-opacity)); +} + .text-white { --tw-text-opacity: 1; color: rgb(255 255 255 / var(--tw-text-opacity)); } +.text-yellow-800 { + --tw-text-opacity: 1; + color: rgb(133 77 14 / var(--tw-text-opacity)); +} + .hover\:cursor-pointer:hover { cursor: pointer; } @@ -732,6 +810,16 @@ video { background-color: rgb(209 213 219 / var(--tw-bg-opacity)); } +.focus\:border-blue-500:focus { + --tw-border-opacity: 1; + border-color: rgb(59 130 246 / var(--tw-border-opacity)); +} + +.focus\:outline-none:focus { + outline: 2px solid transparent; + outline-offset: 2px; +} + .active\:bg-purple-700:active { --tw-bg-opacity: 1; background-color: rgb(126 34 206 / var(--tw-bg-opacity)); @@ -743,6 +831,11 @@ video { } @media (prefers-color-scheme: dark) { + .dark\:border-gray-600 { + --tw-border-opacity: 1; + border-color: rgb(75 85 99 / var(--tw-border-opacity)); + } + .dark\:bg-gray-700 { --tw-bg-opacity: 1; background-color: rgb(55 65 81 / var(--tw-bg-opacity)); @@ -758,6 +851,11 @@ video { color: rgb(209 213 219 / var(--tw-text-opacity)); } + .dark\:text-white { + --tw-text-opacity: 1; + color: rgb(255 255 255 / var(--tw-text-opacity)); + } + .hover\:dark\:text-gray-700:hover { --tw-text-opacity: 1; color: rgb(55 65 81 / var(--tw-text-opacity)); diff --git a/apps/cacvote-jx-terminal/frontend/src/layouts/app_layout.rs b/apps/cacvote-jx-terminal/frontend/src/layouts/app_layout.rs index 755787831..ed003aa7c 100644 --- a/apps/cacvote-jx-terminal/frontend/src/layouts/app_layout.rs +++ b/apps/cacvote-jx-terminal/frontend/src/layouts/app_layout.rs @@ -12,6 +12,7 @@ pub fn AppLayout(cx: Scope) -> Element { use_shared_state_provider(cx, SessionData::default); let session_data = use_shared_state::(cx).unwrap(); let nav = use_navigator(cx); + let route: Route = use_route(cx).unwrap(); use_coroutine(cx, { to_owned![nav, session_data]; @@ -21,15 +22,17 @@ pub fn AppLayout(cx: Scope) -> Element { let callback = Closure::wrap(Box::new(move |event: MessageEvent| { if let Some(data) = event.data().as_string() { - log::info!("received status event: {:?}", data); + log::info!("received status event: {data:?}"); match serde_json::from_str::(&data) { Ok(new_session_data) => { - log::info!("updating session data: {:?}", new_session_data); + log::info!("updating session data: {new_session_data:?}"); match new_session_data { SessionData::Authenticated { .. } => { - log::info!("redirecting to elections page"); - nav.push(Route::ElectionsPage); + if matches!(route, Route::MachineLockedPage) { + log::info!("redirecting to elections page"); + nav.push(Route::ElectionsPage); + } } SessionData::Unauthenticated { .. } => { log::info!("redirecting to machine locked page"); diff --git a/apps/cacvote-jx-terminal/frontend/src/pages/ballots_page.rs b/apps/cacvote-jx-terminal/frontend/src/pages/ballots_page.rs index 9a394e131..97c8a8aa1 100644 --- a/apps/cacvote-jx-terminal/frontend/src/pages/ballots_page.rs +++ b/apps/cacvote-jx-terminal/frontend/src/pages/ballots_page.rs @@ -1,8 +1,178 @@ use dioxus::prelude::*; +use types_rs::{ + cacvote::{self, SessionData}, + cdf::cvr::Cvr, +}; +use ui_rs::DateOrDateTimeCell; + +use crate::components::ElectionConfigurationCell; pub fn BallotsPage(cx: Scope) -> Element { + let session_data = use_shared_state::(cx).unwrap(); + let SessionData::Authenticated { + elections, + cast_ballots, + .. + } = &*session_data.read() + else { + return render!(h1 { class: "text-2xl font-bold", "Please log in to view this page" }); + }; + render!( - h1 { class: "text-2xl font-bold mb-4", "Printed Ballots" } - rsx!("No printed ballots") + h1 { class: "text-2xl font-bold mb-4", "Cast Ballots" } + if cast_ballots.is_empty() { + rsx!("No cast ballots") + } else { + rsx!(CastBallotsTable { + elections: elections.clone(), + cast_ballots: cast_ballots.clone(), + }) + } + ) +} + +#[derive(PartialEq, Props)] +struct CastBallotsTableProps { + elections: Vec, + cast_ballots: Vec, +} + +fn summarize_cast_vote_record(cvr: &Cvr) -> String { + let mut summary = String::new(); + + for snapshot in cvr.cvr_snapshot.iter() { + if let Some(ref contests) = snapshot.cvr_contest { + for contest in contests { + if let Some(ref contest_selections) = contest.cvr_contest_selection { + for contest_selection in contest_selections { + if let Some(ref contest_selection_id) = + contest_selection.contest_selection_id + { + summary.push_str(&format!( + "{}: {}\n", + contest.contest_id, contest_selection_id + )); + } + } + } + } + } + } + + summary +} + +fn CastBallotsTable(cx: Scope) -> Element { + let elections = &cx.props.elections; + + let get_election_by_id = + |election_id| elections.iter().find(|election| election.id == election_id); + + render!( + div { + rsx!( + table { class: "table-auto w-full", + thead { + tr { + th { class: "px-4 py-2 text-left", "Election Configuration" } + th { class: "px-4 py-2 text-left", "Cast Vote Record" } + th { class: "px-4 py-2 text-left", "Created At" } + } + } + tbody { + for cast_ballot in cx.props.cast_ballots.iter() { + { + let election = get_election_by_id(cast_ballot.election_object_id).unwrap(); + + rsx!(tr { + ElectionConfigurationCell { + election_title: election.election_definition.election.title.clone(), + election_hash: election.election_hash.clone(), + precinct_id: cast_ballot.registration().precinct_id.clone(), + ballot_style_id: cast_ballot.registration().ballot_style_id.clone(), + } + td { + class: "border px-4 py-2 whitespace-nowrap", + rsx!( + match cast_ballot.verification_status() { + cacvote::VerificationStatus::Success { common_access_card_id, display_name } => { + rsx!(span { + class: "text-sm p-1 ps-0 pe-2 text-green-800 bg-green-300 font-semibold rounded-md", + title: "{display_name}", + span { + class: "text-sm p-1 ps-2 pe-2 text-white bg-gray-400 font-semibold rounded-l-md", + "CAC #{common_access_card_id}" + } + span { + class: "ps-2", + "Verified" + } + }) + } + cacvote::VerificationStatus::Failure => { + rsx!(span { + class: "text-sm p-1 ps-0 pe-2 text-red-800 bg-red-300 font-semibold rounded-md", + span { + class: "text-sm p-1 ps-2 pe-2 text-white bg-gray-400 font-semibold rounded-l-md", + "CAC" + } + span { + class: "ps-2", + "Unverified" + } + }) + } + cacvote::VerificationStatus::Error(err) => { + rsx!(span { + class: "text-sm p-1 ps-0 pe-2 text-orange-800 bg-orange-300 font-semibold rounded-md", + span { + class: "text-sm p-1 ps-2 pe-2 text-white bg-gray-400 font-semibold rounded-l-md", + "CAC" + } + span { + class: "ps-2", + title: "{err}", + "Error" + } + }) + } + cacvote::VerificationStatus::Unknown => { + rsx!(span { + class: "text-sm p-1 ps-0 pe-2 text-yellow-800 bg-yellow-300 font-semibold rounded-md", + span { + class: "text-sm p-1 ps-2 pe-2 text-white bg-gray-400 font-semibold rounded-l-md", + "CAC" + } + span { + class: "ps-2", + "Unknown" + } + }) + } + } + details { + rsx!(summary { + class: "text-gray-200", + "DEBUG" + }) + { + let summary = summarize_cast_vote_record(&cast_ballot.cvr); + rsx!(pre { + summary + }) + } + } + ) + } + DateOrDateTimeCell { + date_or_datetime: cast_ballot.created_at(), + } + }) + } + } + } + } + ) + } ) } 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 0d0a31633..cccac9269 100644 --- a/apps/cacvote-jx-terminal/frontend/src/pages/voters_page.rs +++ b/apps/cacvote-jx-terminal/frontend/src/pages/voters_page.rs @@ -6,17 +6,14 @@ 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" }) - } + let SessionData::Authenticated { + elections, + pending_registration_requests, + registrations, + .. + } = &*session_data.read() + else { + return render!(h1 { class: "text-2xl font-bold", "Please log in to view this page" }); }; render!( diff --git a/apps/cacvote-mark/backend/src/app.ts b/apps/cacvote-mark/backend/src/app.ts index 43f78f476..ed9f55a24 100644 --- a/apps/cacvote-mark/backend/src/app.ts +++ b/apps/cacvote-mark/backend/src/app.ts @@ -1,5 +1,6 @@ import { cac } from '@votingworks/auth'; import { + asyncResultBlock, err, ok, Optional, @@ -8,7 +9,9 @@ import { } from '@votingworks/basics'; import * as grout from '@votingworks/grout'; import { + BallotIdSchema, BallotStyleId, + BallotType, ElectionDefinition, PrecinctId, unsafeParse, @@ -16,18 +19,23 @@ import { } from '@votingworks/types'; import express, { Application } from 'express'; import { isDeepStrictEqual } from 'util'; -import { v4 } from 'uuid'; import { DateTime } from 'luxon'; +import { buildCastVoteRecord, VX_MACHINE_ID } from '@votingworks/backend'; +import { execFileSync } from 'child_process'; +import { z } from 'zod'; +import * as mailingLabel from './mailing_label'; import { Auth, AuthStatus } from './types/auth'; import { Workspace } from './workspace'; import { + CastBallot, + Election, JurisdictionCode, Payload, RegistrationRequest, SignedObject, Uuid, - UuidSchema, } from './cacvote-server/types'; +import { MAILING_LABEL_PRINTER } from './globals'; export type VoterStatus = | 'unregistered' @@ -155,7 +163,7 @@ function buildApi({ } const certificates = await auth.getCertificate(); - const objectId = unsafeParse(UuidSchema, v4()); + const objectId = Uuid(); const object = new SignedObject( objectId, payload, @@ -181,17 +189,153 @@ function buildApi({ return undefined; } - // TODO: get election configuration for the user - return undefined; + const registrationInfo = store + .forEachRegistration({ + commonAccessCardId: authStatus.card.commonAccessCardId, + }) + .first(); + + if (!registrationInfo) { + return undefined; + } + + const electionObjectId = + registrationInfo.registration.getElectionObjectId(); + const electionObject = store.getObjectById(electionObjectId); + + if (!electionObject) { + throw new Error(`Election not found: ${electionObjectId}`); + } + + const electionPayloadResult = electionObject.getPayload(); + + if (electionPayloadResult.isErr()) { + throw new Error(electionPayloadResult.err().message); + } + + const electionPayload = electionPayloadResult.ok(); + const election = electionPayload.getData(); + + if (!(election instanceof Election)) { + throw new Error( + `Expected 'Election' but was ${electionPayload.getObjectType()}` + ); + } + + return { + electionDefinition: election.getElectionDefinition(), + ballotStyleId: registrationInfo.registration.getBallotStyleId(), + precinctId: registrationInfo.registration.getPrecinctId(), + }; }, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - castBallot(_input: { + castBallot(input: { votes: VotesDict; pin: string; - }): Promise> { - // TODO: cast and print the ballot - throw new Error('Not implemented'); + }): Promise< + Result< + { id: Uuid }, + cac.GenerateSignatureError | SyntaxError | z.ZodError + > + > { + return asyncResultBlock(async (bail) => { + const authStatus = await getAuthStatus(); + + if (authStatus.status !== 'has_card') { + throw new Error('Not logged in'); + } + + const { commonAccessCardId } = authStatus.card; + // TODO: Handle multiple registrations + const registration = store + .forEachRegistration({ commonAccessCardId }) + .first(); + + if (!registration) { + throw new Error('Not registered'); + } + + const electionObjectId = + registration.registration.getElectionObjectId(); + const electionObject = store.getObjectById(electionObjectId); + + if (!electionObject) { + throw new Error(`Election not found: ${electionObjectId}`); + } + + const electionPayload = electionObject.getPayload().okOrElse(bail); + const election = electionPayload.getData(); + + if (!(election instanceof Election)) { + throw new Error( + `Expected 'Election' but was ${electionPayload.getObjectType()}` + ); + } + + const electionDefinition = election.getElectionDefinition(); + if (!electionDefinition) { + throw new Error('no election definition found for registration'); + } + + const ballotId = Uuid(); + const castVoteRecordId = unsafeParse(BallotIdSchema, ballotId); + const castVoteRecord = buildCastVoteRecord({ + electionDefinition, + electionId: electionDefinition.electionHash, + scannerId: VX_MACHINE_ID, + // TODO: what should the batch ID be? + batchId: '', + castVoteRecordId, + interpretation: { + type: 'InterpretedBmdPage', + metadata: { + ballotStyleId: registration.registration.getBallotStyleId(), + precinctId: registration.registration.getPrecinctId(), + ballotType: BallotType.Absentee, + electionHash: electionDefinition.electionHash, + // TODO: support test mode + isTestMode: false, + }, + votes: input.votes, + }, + ballotMarkingMode: 'machine', + }); + + const pdf = await mailingLabel.buildPdf(); + + execFileSync( + 'lpr', + ['-P', MAILING_LABEL_PRINTER, '-o', 'media=Custom.4x6in'], + { input: pdf } + ); + + const payload = Payload.CastBallot( + new CastBallot( + authStatus.card.commonAccessCardId, + election.getJurisdictionCode(), + registration.registration.getRegistrationRequestObjectId(), + registration.object.getId(), + electionObjectId, + castVoteRecord + ) + ); + + const signature = ( + await auth.generateSignature(payload.toBuffer(), { pin: input.pin }) + ).okOrElse(bail); + const commonAccessCardCertificate = await auth.getCertificate(); + const objectId = Uuid(); + const object = new SignedObject( + objectId, + payload.toBuffer(), + commonAccessCardCertificate, + signature + ); + + (await store.addObject(object)).unsafeUnwrap(); + + return ok({ id: objectId }); + }); }, logOut() { diff --git a/apps/cacvote-mark/backend/src/cacvote-server/client.ts b/apps/cacvote-mark/backend/src/cacvote-server/client.ts index 0bf077fe7..f315a9fea 100644 --- a/apps/cacvote-mark/backend/src/cacvote-server/client.ts +++ b/apps/cacvote-mark/backend/src/cacvote-server/client.ts @@ -52,7 +52,7 @@ export class Client { async createObject(signedObject: SignedObject): Promise> { return asyncResultBlock(async (bail) => { const response = ( - await this.post('/api/objects', JSON.stringify(signedObject)) + await this.post('/api/objects', JSON.stringify(signedObject, null, 2)) ).okOrElse(bail); if (!response.ok) { diff --git a/apps/cacvote-mark/backend/src/cacvote-server/types.ts b/apps/cacvote-mark/backend/src/cacvote-server/types.ts index 47d44ec1a..780fde7db 100644 --- a/apps/cacvote-mark/backend/src/cacvote-server/types.ts +++ b/apps/cacvote-mark/backend/src/cacvote-server/types.ts @@ -1,9 +1,15 @@ // eslint-disable-next-line max-classes-per-file import { certs, cryptography } from '@votingworks/auth'; -import { Result, ok, wrapException } from '@votingworks/basics'; +import { + Result, + ok, + throwIllegalValue, + wrapException, +} from '@votingworks/basics'; import { BallotStyleId, BallotStyleIdSchema, + CVR, ElectionDefinition, NewType, PrecinctId, @@ -20,6 +26,7 @@ import { ZodError, z } from 'zod'; export const ElectionObjectType = 'Election'; export const RegistrationRequestObjectType = 'RegistrationRequest'; export const RegistrationObjectType = 'Registration'; +export const CastBallotObjectType = 'CastBallot'; export type Uuid = NewType; export function Uuid(): Uuid { @@ -90,7 +97,7 @@ export class JournalEntry { } } -export const RawJournalEntrySchema: z.ZodSchema<{ +export const JournalEntryStructSchema: z.ZodSchema<{ id: Uuid; objectId: Uuid; jurisdictionCode: JurisdictionCode; @@ -107,7 +114,7 @@ export const RawJournalEntrySchema: z.ZodSchema<{ }); export const JournalEntrySchema: z.ZodSchema = - RawJournalEntrySchema.transform( + JournalEntryStructSchema.transform( (o) => new JournalEntry( o.id, @@ -271,7 +278,68 @@ export const RegistrationSchema: z.ZodSchema = ) ) as unknown as z.ZodSchema; -export type PayloadInner = Election | Registration | RegistrationRequest; +export class CastBallot { + constructor( + private readonly commonAccessCardId: string, + private readonly jurisdictionCode: JurisdictionCode, + private readonly registrationRequestObjectId: Uuid, + private readonly registrationObjectId: Uuid, + private readonly electionObjectId: Uuid, + private readonly cvr: CVR.CVR + ) {} + + getCommonAccessCardId(): string { + return this.commonAccessCardId; + } + + getJurisdictionCode(): JurisdictionCode { + return this.jurisdictionCode; + } + + getRegistrationRequestObjectId(): Uuid { + return this.registrationRequestObjectId; + } + + getRegistrationObjectId(): Uuid { + return this.registrationObjectId; + } + + getElectionObjectId(): Uuid { + return this.electionObjectId; + } + + getCVR(): CVR.CVR { + return this.cvr; + } +} + +const CastBallotStructSchema = z.object({ + commonAccessCardId: z.string(), + jurisdictionCode: JurisdictionCodeSchema, + registrationRequestObjectId: UuidSchema, + registrationObjectId: UuidSchema, + electionObjectId: UuidSchema, + cvr: CVR.CVRSchema, +}); + +export const CastBallotSchema: z.ZodSchema = + CastBallotStructSchema.transform( + (o) => + new CastBallot( + o.commonAccessCardId, + o.jurisdictionCode, + o.registrationRequestObjectId, + o.registrationObjectId, + o.electionObjectId, + o.cvr + ) + ) as unknown as z.ZodSchema; + +export type PayloadInner = + | Election + | Registration + | RegistrationRequest + | CastBallot; export class Payload { constructor( @@ -312,6 +380,10 @@ export class Payload { ): Payload { return new Payload(RegistrationRequestObjectType, data); } + + static CastBallot(data: CastBallot): Payload { + return new Payload(CastBallotObjectType, data); + } } export const PayloadSchema: z.ZodSchema = z @@ -325,40 +397,59 @@ export const PayloadSchema: z.ZodSchema = z z .object({ objectType: z.literal(RegistrationRequestObjectType) }) .merge(RegistrationRequestStructSchema), + z + .object({ objectType: z.literal(CastBallotObjectType) }) + .merge(CastBallotStructSchema), ]) .transform((o) => { - if (o.objectType === ElectionObjectType) { - return Payload.Election( - new Election(o.jurisdictionCode, o.electionDefinition) - ); + switch (o.objectType) { + case ElectionObjectType: { + return Payload.Election( + new Election(o.jurisdictionCode, o.electionDefinition) + ); + } + + case RegistrationObjectType: { + return Payload.Registration( + new Registration( + o.commonAccessCardId, + o.jurisdictionCode, + o.registrationRequestObjectId, + o.electionObjectId, + o.ballotStyleId, + o.precinctId + ) + ); + } + + case RegistrationRequestObjectType: { + return Payload.RegistrationRequest( + new RegistrationRequest( + o.commonAccessCardId, + o.jurisdictionCode, + o.givenName, + o.familyName, + o.createdAt + ) + ); + } + + case CastBallotObjectType: { + return Payload.CastBallot( + new CastBallot( + o.commonAccessCardId, + o.jurisdictionCode, + o.registrationRequestObjectId, + o.registrationObjectId, + o.electionObjectId, + o.cvr + ) + ); + } + + default: + throwIllegalValue(o); } - - if (o.objectType === RegistrationObjectType) { - return Payload.Registration( - new Registration( - o.commonAccessCardId, - o.jurisdictionCode, - o.registrationRequestObjectId, - o.electionObjectId, - o.ballotStyleId, - o.precinctId - ) - ); - } - - if (o.objectType === RegistrationRequestObjectType) { - return Payload.RegistrationRequest( - new RegistrationRequest( - o.commonAccessCardId, - o.jurisdictionCode, - o.givenName, - o.familyName, - o.createdAt - ) - ); - } - - throw new Error(`Unknown object type: ${JSON.stringify(o)}`); }) as unknown as z.ZodSchema; export class SignedObject { diff --git a/apps/cacvote-mark/backend/src/server.ts b/apps/cacvote-mark/backend/src/server.ts index 0955055b1..f40e79efa 100644 --- a/apps/cacvote-mark/backend/src/server.ts +++ b/apps/cacvote-mark/backend/src/server.ts @@ -1,5 +1,5 @@ import { cac } from '@votingworks/auth'; -import { assertDefined, throwIllegalValue } from '@votingworks/basics'; +import { throwIllegalValue } from '@votingworks/basics'; import { LogEventId, Logger } from '@votingworks/logging'; import { Server } from 'http'; import { buildApp } from './app'; @@ -36,7 +36,12 @@ function getDefaultAuth(): Auth { return { status: 'no_card' }; case 'ready': { - const cardDetails = assertDefined(status.cardDetails); + const { cardDetails } = status; + + if (!cardDetails) { + return { status: 'no_card' }; + } + return { status: 'has_card', card: cardDetails, diff --git a/libs/types-rs/src/cacvote/mod.rs b/libs/types-rs/src/cacvote/mod.rs index d2e3dfa96..a1ccb7651 100644 --- a/libs/types-rs/src/cacvote/mod.rs +++ b/libs/types-rs/src/cacvote/mod.rs @@ -7,6 +7,7 @@ use serde::{Deserialize, Serialize}; use time::OffsetDateTime; use uuid::Uuid; +use crate::cdf::cvr::Cvr; use crate::election::BallotStyleId; use crate::election::ElectionDefinition; use crate::election::ElectionHash; @@ -197,6 +198,7 @@ pub enum Payload { RegistrationRequest(RegistrationRequest), Registration(Registration), Election(Election), + CastBallot(CastBallot), } impl Payload { @@ -205,6 +207,7 @@ impl Payload { Self::RegistrationRequest(_) => Self::registration_request_object_type(), Self::Registration(_) => Self::registration_object_type(), Self::Election(_) => Self::election_object_type(), + Self::CastBallot(_) => Self::cast_ballot_object_type(), } } @@ -225,6 +228,12 @@ impl Payload { // `Payload` enum. "Election" } + + pub fn cast_ballot_object_type() -> &'static str { + // This must match the naming rules of the `serde` attribute in the + // `Payload` enum. + "CastBallot" + } } impl JurisdictionScoped for Payload { @@ -233,6 +242,7 @@ impl JurisdictionScoped for Payload { Self::RegistrationRequest(request) => request.jurisdiction_code(), Self::Registration(registration) => registration.jurisdiction_code(), Self::Election(election) => election.jurisdiction_code(), + Self::CastBallot(cast_ballot) => cast_ballot.jurisdiction_code(), } } } @@ -484,6 +494,108 @@ impl Deref for Election { } } +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CastBallot { + pub common_access_card_id: String, + pub jurisdiction_code: JurisdictionCode, + pub registration_request_object_id: Uuid, + pub registration_object_id: Uuid, + pub election_object_id: Uuid, + pub cvr: Cvr, +} + +impl CastBallot { + pub fn registration_request_object_id_field_name() -> &'static str { + // This must match the naming rules of the `serde` attribute in the + // `CastBallot` struct. + "registrationRequestObjectId" + } + + pub fn registration_object_id_field_name() -> &'static str { + // This must match the naming rules of the `serde` attribute in the + // `CastBallot` struct. + "registrationObjectId" + } + + pub fn election_object_id_field_name() -> &'static str { + // This must match the naming rules of the `serde` attribute in the + // `CastBallot` struct. + "electionObjectId" + } +} + +impl JurisdictionScoped for CastBallot { + fn jurisdiction_code(&self) -> JurisdictionCode { + self.jurisdiction_code.clone() + } +} + +impl Deref for CastBallot { + type Target = Cvr; + + fn deref(&self) -> &Self::Target { + &self.cvr + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CastBallotPresenter { + cast_ballot: CastBallot, + registration_request: RegistrationRequest, + registration: Registration, + verification_status: VerificationStatus, + #[serde(with = "time::serde::iso8601")] + created_at: OffsetDateTime, +} + +impl CastBallotPresenter { + pub const fn new( + cast_ballot: CastBallot, + registration_request: RegistrationRequest, + registration: Registration, + verification_status: VerificationStatus, + created_at: OffsetDateTime, + ) -> Self { + Self { + cast_ballot, + registration_request, + registration, + verification_status, + created_at, + } + } + + pub fn registration(&self) -> &Registration { + &self.registration + } + + pub fn registration_request(&self) -> &RegistrationRequest { + &self.registration_request + } + + pub fn cvr(&self) -> &Cvr { + &self.cvr + } + + pub fn created_at(&self) -> OffsetDateTime { + self.created_at + } + + pub fn verification_status(&self) -> &VerificationStatus { + &self.verification_status + } +} + +impl Deref for CastBallotPresenter { + type Target = CastBallot; + + fn deref(&self) -> &Self::Target { + &self.cast_ballot + } +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum SessionData { @@ -492,6 +604,7 @@ pub enum SessionData { elections: Vec, pending_registration_requests: Vec, registrations: Vec, + cast_ballots: Vec, }, Unauthenticated { has_smartcard: bool, diff --git a/libs/types-rs/src/cdf/cvr.rs b/libs/types-rs/src/cdf/cvr.rs index 9d4900ae4..8df79fb20 100644 --- a/libs/types-rs/src/cdf/cvr.rs +++ b/libs/types-rs/src/cdf/cvr.rs @@ -497,10 +497,6 @@ pub struct Cvr { /// The sequence number for this CVR. This represents the ordinal number that this CVR was processed by the tabulating device. #[serde(rename = "UniqueId", skip_serializing_if = "Option::is_none")] pub unique_id: Option, - - /// Indicates whether the ballot is an absentee or precinct ballot. - #[serde(rename = "vxBallotType")] - pub vx_ballot_type: VxBallotType, } impl Default for Cvr { @@ -521,49 +517,6 @@ impl Default for Cvr { election_id: "".to_string(), party_ids: None, unique_id: None, - vx_ballot_type: VxBallotType::Precinct, - } - } -} - -/// Used in `CVR::vxBallotType` to indicate whether the ballot is an absentee or -/// precinct ballot. -#[derive(Clone, Eq, Hash, PartialEq, Debug, Deserialize, Serialize)] -pub enum VxBallotType { - #[serde(rename = "precinct")] - Precinct, - - #[serde(rename = "absentee")] - Absentee, - - #[serde(rename = "provisional")] - Provisional, -} - -impl VxBallotType { - pub fn max() -> u32 { - // updating this value is a breaking change - 2u32.pow(4) - 1 - } -} - -impl From for u32 { - fn from(vx_ballot_type: VxBallotType) -> Self { - match vx_ballot_type { - VxBallotType::Precinct => 0, - VxBallotType::Absentee => 1, - VxBallotType::Provisional => 2, - } - } -} - -impl From for VxBallotType { - fn from(vx_ballot_type: u32) -> Self { - match vx_ballot_type { - 0 => VxBallotType::Precinct, - 1 => VxBallotType::Absentee, - 2 => VxBallotType::Provisional, - _ => panic!("Invalid VxBallotType"), } } } @@ -959,56 +912,6 @@ pub struct CastVoteRecordReport { /// The version of the CVR specification being used (1.0). #[serde(rename = "Version")] pub version: CastVoteRecordVersion, - - /// List of scanner batches with metadata. - #[serde(rename = "vxBatch")] - vx_batch: Vec, -} - -#[derive(Clone, Eq, Hash, PartialEq, Debug, Deserialize, Serialize, Default)] -pub enum VxBatchObjectType { - #[serde(rename = "CVR.vxBatch")] - #[default] - VxBatch, -} - -/// Entity containing metadata about a scanned batch. Cast vote records link to batches via CVR::BatchId. -#[derive(Clone, Eq, Hash, PartialEq, Debug, Deserialize, Serialize)] -pub struct VxBatch { - #[serde(rename = "@id")] - pub id: String, - - #[serde(rename = "@type")] - pub object_type: VxBatchObjectType, - - /// A human readable label for the batch. - #[serde(rename = "BatchLabel")] - batch_label: String, - - /// The ordinal number of the batch in the tabulator's sequence of batches in a given election. - #[serde(rename = "SequenceId")] - sequence_id: u64, - - /// The start time of the batch. On a precinct scanner, the start time is when the polls are opened or voting is resumed. On a central scanner, the start time is when the user initiates scanning a batch. - #[serde(rename = "StartTime", with = "time::serde::iso8601")] - start_time: OffsetDateTime, - - /// The end time of the batch. On a precinct scanner, the end time is when the polls are closed or voting is paused. On a central scanner, the end time is when a batch scan is complete - #[serde( - rename = "EndTime", - default, - skip_serializing_if = "Option::is_none", - with = "time::serde::iso8601::option" - )] - end_time: Option, - - /// The number of sheets included in a batch. - #[serde(rename = "NumberSheets")] - number_sheets: u64, - - /// The tabulator that created the batch. - #[serde(rename = "CreatingDeviceId")] - creating_device_id: String, } #[derive(Clone, Eq, Hash, PartialEq, Debug, Deserialize, Serialize, Default)] diff --git a/services/cacvote-server/src/db.rs b/services/cacvote-server/src/db.rs index d56f2abf3..20452b33b 100644 --- a/services/cacvote-server/src/db.rs +++ b/services/cacvote-server/src/db.rs @@ -43,7 +43,7 @@ pub async fn create_object( let Some(jurisdiction_code) = object.jurisdiction_code() else { tracing::error!( - "no jurisdiction found in object: {:?} (try_to_inner={:?}", + "no jurisdiction found in object: {:?} (try_to_inner={:?})", object, object.try_to_inner(), );