Skip to content

Commit

Permalink
Configure from USB drive (#36)
Browse files Browse the repository at this point in the history
* Fix clipping by removing unnecessary overflow css rule

* Configure from USB drive
  • Loading branch information
jonahkagan authored Jan 9, 2025
1 parent dd74cfe commit 3d518ec
Show file tree
Hide file tree
Showing 15 changed files with 320 additions and 26 deletions.
2 changes: 2 additions & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"@votingworks/backend": "workspace:*",
"@votingworks/basics": "workspace:*",
"@votingworks/db": "workspace:*",
"@votingworks/fs": "workspace:*",
"@votingworks/grout": "workspace:*",
"@votingworks/logging": "workspace:*",
"@votingworks/printing": "workspace:*",
Expand All @@ -39,6 +40,7 @@
"@votingworks/usb-drive": "workspace:*",
"@votingworks/utils": "workspace:*",
"canvas": "2.11.2",
"csv-parse": "^5.6.0",
"debug": "4.3.4",
"dotenv": "16.3.1",
"dotenv-expand": "9.0.0",
Expand Down
147 changes: 141 additions & 6 deletions backend/src/app.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,148 @@
import * as grout from '@votingworks/grout';
import express, { Application } from 'express';
import { sleep } from '@votingworks/basics';
import { err, ok, Result, sleep } from '@votingworks/basics';
import { UsbDrive } from '@votingworks/usb-drive';
import { readFile, ReadFileError } from '@votingworks/fs';
import { join } from 'node:path';
import {
getEntries,
getFileByName,
openZip,
readTextEntry,
} from '@votingworks/utils';
import { safeParseJson } from '@votingworks/types';
import { parse } from 'csv-parse/sync';
import { setInterval } from 'node:timers/promises';
import { Workspace } from './workspace';
import { Voter, VoterIdentificationMethod, VoterSearchParams } from './types';
import {
ElectionConfiguration,
ElectionConfigurationSchema,
PollbookPackage,
Voter,
VoterIdentificationMethod,
VoterSearchParams,
} from './types';
import { rootDebug } from './debug';

const debug = rootDebug;

export interface AppContext {
workspace: Workspace;
usbDrive: UsbDrive;
}

// TODO read machine ID from env or network
const machineId = 'placeholder-machine-id';

function buildApi(workspace: Workspace) {
const MEGABYTE = 1024 * 1024;
const MAX_POLLBOOK_PACKAGE_SIZE = 10 * MEGABYTE;

function toCamelCase(str: string) {
const words = str
.split(/[^a-zA-Z0-9]/)
.filter((word) => word.length > 0)
.map((word) => word.toLowerCase());
const first = words.shift();
const rest = words.map((word) => word[0].toUpperCase() + word.slice(1));
return [first, ...rest].join('');
}

async function readPollbookPackage(
path: string
): Promise<Result<PollbookPackage, ReadFileError>> {
const pollbookPackage = await readFile(path, {
maxSize: MAX_POLLBOOK_PACKAGE_SIZE,
});
if (pollbookPackage.isErr()) {
return err(pollbookPackage.err());
}
const zipFile = await openZip(pollbookPackage.ok());
const zipName = 'pollbook package';
const entries = getEntries(zipFile);

const electionEntry = getFileByName(entries, 'election.json', zipName);
const electionJsonString = await readTextEntry(electionEntry);
const election: ElectionConfiguration = safeParseJson(
electionJsonString,
ElectionConfigurationSchema
).unsafeUnwrap();

const votersEntry = getFileByName(entries, 'voters.csv', zipName);
const votersCsvString = await readTextEntry(votersEntry);
const voters = parse(votersCsvString, {
columns: (header) => header.map(toCamelCase),
skipEmptyLines: true,
// Filter out metadata row at the end
onRecord: (record) => (record.voterId ? record : null),
}) as Voter[];

return ok({ election, voters });
}

type ConfigurationStatus = 'loading' | 'not-found';
let configurationStatus: ConfigurationStatus | undefined;

function pollUsbDriveForPollbookPackage({ workspace, usbDrive }: AppContext) {
debug('Polling USB drive for pollbook package');
if (workspace.store.getElectionConfiguration()) {
return;
}
process.nextTick(async () => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
for await (const _ of setInterval(100)) {
const usbDriveStatus = await usbDrive.status();
if (usbDriveStatus.status === 'mounted') {
debug('Found USB drive mounted at %s', usbDriveStatus.mountPoint);
configurationStatus = 'loading';
const pollbookPackageResult = await readPollbookPackage(
join(usbDriveStatus.mountPoint, 'pollbook-package.zip')
);
if (pollbookPackageResult.isErr()) {
const result = pollbookPackageResult.err();
debug('Read pollbook package error: %O', result);
if (
result.type === 'OpenFileError' &&
'code' in result.error &&
result.error.code === 'ENOENT'
) {
configurationStatus = 'not-found';
} else {
throw result;
}
configurationStatus = 'not-found';
continue;
}
const pollbookPackage = pollbookPackageResult.ok();
workspace.store.setElectionAndVoters(
pollbookPackage.election,
pollbookPackage.voters
);
configurationStatus = undefined;
debug('Configured with pollbook package: %O', {
election: pollbookPackage.election,
voters: pollbookPackage.voters.length,
});
break;
}
}
});
}

function buildApi({ workspace }: AppContext) {
const { store } = workspace;

return grout.createApi({
getElectionConfiguration(): Result<
ElectionConfiguration,
'unconfigured' | ConfigurationStatus
> {
if (configurationStatus) {
return err(configurationStatus);
}
const election = store.getElectionConfiguration();
return election ? ok(election) : err('unconfigured');
},

searchVoters(input: {
searchParams: VoterSearchParams;
}): Voter[] | number | null {
Expand Down Expand Up @@ -49,10 +181,13 @@ function buildApi(workspace: Workspace) {

export type Api = ReturnType<typeof buildApi>;

export function buildApp(workspace: Workspace): Application {
export function buildApp(context: AppContext): Application {
const app: Application = express();
const api = buildApi(workspace);
const api = buildApi(context);
app.use('/api', grout.buildRouter(api, express));
app.use(express.static(workspace.assetDirectoryPath));
app.use(express.static(context.workspace.assetDirectoryPath));

pollUsbDriveForPollbookPackage(context);

return app;
}
4 changes: 4 additions & 0 deletions backend/src/backup_worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,10 @@ export function start({
process.nextTick(async () => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
for await (const _ of setInterval(BACKUP_INTERVAL)) {
if (!workspace.store.getElectionConfiguration()) {
console.log('Machine not configured, skipping backup');
continue;
}
console.log('Exporting backup voter checklist');
await exportBackupVoterChecklist(workspace, usbDrive);
}
Expand Down
2 changes: 1 addition & 1 deletion backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ function main(): Promise<number> {
Logger.from(logger, () => Promise.resolve('system'))
);

server.start({ workspace });
server.start({ workspace, usbDrive });
backupWorker.start({ workspace, usbDrive });

return Promise.resolve(0);
Expand Down
7 changes: 3 additions & 4 deletions backend/src/server.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import { buildApp } from './app';
import { AppContext, buildApp } from './app';
import { PORT } from './globals';
import { Workspace } from './workspace';

/**
* Starts the server.
*/
export function start({ workspace }: { workspace: Workspace }): void {
const app = buildApp(workspace);
export function start(context: AppContext): void {
const app = buildApp(context);

app.listen(PORT, () => {
// eslint-disable-next-line no-console
Expand Down
41 changes: 31 additions & 10 deletions backend/src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,18 @@ import { Client as DbClient } from '@votingworks/db';
import { join } from 'node:path';
// import { v4 as uuid } from 'uuid';
import { BaseLogger } from '@votingworks/logging';
import { find, groupBy } from '@votingworks/basics';
import { readFileSync } from 'node:fs';
import { Voter, VoterIdentificationMethod, VoterSearchParams } from './types';
import { assert, find, groupBy } from '@votingworks/basics';
import {
ElectionConfiguration,
Voter,
VoterIdentificationMethod,
VoterSearchParams,
} from './types';

const voters: Voter[] = JSON.parse(
readFileSync(join(__dirname, '../../voters.json'), 'utf-8')
);
const data: {
voters?: Voter[];
electionConfiguration?: ElectionConfiguration;
} = {};

// function convertSqliteTimestampToIso8601(
// sqliteTimestamp: string
Expand Down Expand Up @@ -40,15 +45,29 @@ export class Store {
return new Store(DbClient.memoryClient(SchemaPath));
}

getElectionConfiguration(): ElectionConfiguration | undefined {
return data.electionConfiguration;
}

setElectionAndVoters(
electionConfiguration: ElectionConfiguration,
voters: Voter[]
): void {
data.electionConfiguration = electionConfiguration;
data.voters = voters;
}

groupVotersAlphabeticallyByLastName(): Array<Voter[]> {
return groupBy(voters, (v) => v.lastName[0].toUpperCase()).map(
assert(data.voters);
return groupBy(data.voters, (v) => v.lastName[0].toUpperCase()).map(
([, voterGroup]) => voterGroup
);
}

searchVoters(searchParams: VoterSearchParams): Voter[] | number {
assert(data.voters);
const MAX_VOTER_SEARCH_RESULTS = 20;
const matchingVoters = voters.filter(
const matchingVoters = data.voters.filter(
(voter) =>
voter.lastName
.toUpperCase()
Expand All @@ -68,7 +87,8 @@ export class Store {
identificationMethod: VoterIdentificationMethod,
machineId: string
): void {
const voter = find(voters, (v) => v.voterId === voterId);
assert(data.voters);
const voter = find(data.voters, (v) => v.voterId === voterId);
voter.checkIn = {
timestamp: new Date().toISOString(),
identificationMethod,
Expand All @@ -77,7 +97,8 @@ export class Store {
}

getCheckInCount(machineId?: string): number {
return voters.filter(
assert(data.voters);
return data.voters.filter(
(voter) =>
voter.checkIn && (!machineId || voter.checkIn.machineId === machineId)
).length;
Expand Down
27 changes: 27 additions & 0 deletions backend/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,25 @@
import { DateWithoutTime } from '@votingworks/basics';
import z from 'zod';

export interface ElectionConfiguration {
electionName: string;
electionDate: DateWithoutTime;
precinctName: string;
}

export const ElectionConfigurationSchema: z.ZodSchema<
ElectionConfiguration,
z.ZodTypeDef,
Omit<ElectionConfiguration, 'electionDate'> & { electionDate: string }
> = z.object({
electionName: z.string(),
electionDate: z
.string()
.date()
.transform((date) => new DateWithoutTime(date)),
precinctName: z.string(),
});

export type VoterIdentificationMethod =
| {
type: 'photoId';
Expand Down Expand Up @@ -54,3 +76,8 @@ export interface VoterSearchParams {
lastName: string;
firstName: string;
}

export interface PollbookPackage {
election: ElectionConfiguration;
voters: Voter[];
}
1 change: 1 addition & 0 deletions backend/tsconfig.build.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
{ "path": "../libs/backend/tsconfig.build.json" },
{ "path": "../libs/db/tsconfig.build.json" },
{ "path": "../libs/eslint-plugin-vx/tsconfig.build.json" },
{ "path": "../libs/fs/tsconfig.build.json" },
{ "path": "../libs/fixtures/tsconfig.build.json" },
{ "path": "../libs/grout/tsconfig.build.json" },
{ "path": "../libs/printing/tsconfig.build.json" },
Expand Down
1 change: 1 addition & 0 deletions backend/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
{ "path": "../libs/backend/tsconfig.build.json" },
{ "path": "../libs/db/tsconfig.build.json" },
{ "path": "../libs/eslint-plugin-vx/tsconfig.build.json" },
{ "path": "../libs/fs/tsconfig.build.json" },
{ "path": "../libs/fixtures/tsconfig.build.json" },
{ "path": "../libs/grout/tsconfig.build.json" },
{ "path": "../libs/printing/tsconfig.build.json" },
Expand Down
16 changes: 16 additions & 0 deletions frontend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ import * as grout from '@votingworks/grout';
import {
QueryClient,
QueryKey,
QueryOptions,
useMutation,
useQuery,
useQueryClient,
UseQueryOptions,
} from '@tanstack/react-query';
import { BallotStyleId, BallotType, Id } from '@votingworks/types';
import type {
Expand Down Expand Up @@ -50,6 +52,20 @@ export function createQueryClient(): QueryClient {
});
}

export const getElectionConfiguration = {
queryKey(): QueryKey {
return ['getElectionConfiguration'];
},
useQuery(options: { refetchInterval?: number } = {}) {
const apiClient = useApiClient();
return useQuery(
this.queryKey(),
() => apiClient.getElectionConfiguration(),
options
);
},
} as const;

export const searchVoters = {
queryKey(searchParams?: VoterSearchParams): QueryKey {
return searchParams ? ['searchVoters', searchParams] : ['searchVoters'];
Expand Down
Loading

0 comments on commit 3d518ec

Please sign in to comment.