diff --git a/packages/backend/src/Controllers/Ballot/castVoteController.ts b/packages/backend/src/Controllers/Ballot/castVoteController.ts index ca66555e6..71e6665e5 100644 --- a/packages/backend/src/Controllers/Ballot/castVoteController.ts +++ b/packages/backend/src/Controllers/Ballot/castVoteController.ts @@ -21,19 +21,12 @@ import { Score } from "@equal-vote/star-vote-shared/domain_model/Score"; import { makeUniqueID, ID_LENGTHS, ID_PREFIXES } from "@equal-vote/star-vote-shared/utils/makeID"; const ElectionsModel = ServiceLocator.electionsDb(); -const ElectionRollModel = ServiceLocator.electionRollDb(); const BallotModel = ServiceLocator.ballotsDb(); +import { CastVoteEvent } from "../../Models/CastVoteStore"; const EventQueue = ServiceLocator.eventQueue(); const EmailService = ServiceLocator.emailService(); const AccountService = ServiceLocator.accountService(); -type CastVoteEvent = { - requestId:Uid, - inputBallot:Ballot, - roll?:ElectionRoll, - userEmail?:string, - isBallotUpdate: boolean, -} // NOTE: discord isn't implemented yet, but that's the plan for the future type BallotSubmitType = 'submitted_via_browser' | 'submitted_via_admin' | 'submitted_via_discord'; @@ -74,6 +67,7 @@ async function makeBallotEvent(req: IElectionRequest, targetElection: Election, //some ballot info should be server-authorative // TODO: move to db trigger inputBallot.date_submitted = Date.now(); + inputBallot.status = 'submitted'; if (inputBallot.history == null){ inputBallot.history = []; } @@ -97,8 +91,10 @@ async function makeBallotEvent(req: IElectionRequest, targetElection: Election, async (id: string) => await BallotModel.getBallotByID(id, req) !== null ); } - //TODO, ensure the user ID is added to the ballot... - //should server-authenticate the user id based on auth token + if (!inputBallot.user_id) { + inputBallot.user_id = voter_id || req.user?.sub || undefined; + } + inputBallot.history.push({ action_type: submitType, actor: roll===null ? '' : roll.voter_id , @@ -123,10 +119,10 @@ async function makeBallotEvent(req: IElectionRequest, targetElection: Election, if(req.election.ballot_source !== 'prior_election') Logger.debug(req, "Submit Ballot:", inputBallot); return { - requestId:req.contextId ? req.contextId : randomUUID(), - inputBallot, - roll, - userEmail:undefined, + requestId: req.contextId ? req.contextId : randomUUID(), + inputBallot: inputBallot as Ballot, + roll: roll || undefined, + userEmail: undefined, isBallotUpdate: !!updatableBallot, } } @@ -205,8 +201,26 @@ async function uploadBallotsController(req: IElectionRequest, res: Response, nex req, `Admin submits a ballot for prior election` ) - }else{ - await (await EventQueue).publishBatch(castVoteEventQueue, events.filter(event => !('error' in event))); + } else { + const validEvents = events.filter((event: any) => !('error' in event)) as CastVoteEvent[]; + const successfullySavedEvents: CastVoteEvent[] = []; + for (const event of validEvents) { + const ctx = Logger.createContext(event.requestId); + try { + await ServiceLocator.castVoteStore().submitBallotEvent(event, ctx); + successfullySavedEvents.push(event); + } catch (e: any) { + Logger.error(req, `Could not upload ballot for ${event.roll?.voter_id || event.inputBallot.user_id || 'unknown'}: ${e.message}`); + const index = events.indexOf(event); + if (index !== -1) { + output[index].success = false; + output[index].message = e.message; + } + } + } + if (successfullySavedEvents.length > 0) { + await (await EventQueue).publishBatch(castVoteEventQueue, successfullySavedEvents); + } } }catch(err: any){ const msg = `Could not upload ballots`; @@ -241,6 +255,21 @@ async function castVoteController(req: IElectionRequest, res: Response, next: Ne event.userEmail = event.roll?.email ?? AccountService.extractUserFromRequest(req)?.email ?? req.body.receiptEmail; + const ctx = Logger.createContext(event.requestId); + try { + await ServiceLocator.castVoteStore().submitBallotEvent(event, ctx); + } catch (e: any) { + if (e.message === "ALREADY_VOTED") { + Logger.info(req, "Ballot Rejected. User has already voted."); + throw new BadRequest("User has already voted"); + } + if (e.message === "CONCURRENT_BALLOT_UPDATE_DETECTED" || e.message === "CONCURRENT_ROLL_EDIT_DETECTED") { + Logger.info(req, `Ballot Rejected: ${e.message}`); + throw new BadRequest("Concurrent edit detected, aborting."); + } + throw e; + } + await (await EventQueue).publish(castVoteEventQueue, event); if(io != null){ // necessary for tests @@ -261,24 +290,15 @@ async function castVoteController(req: IElectionRequest, res: Response, next: Ne async function handleCastVoteEvent(job: { id: string; data: CastVoteEvent; }):Promise { const event = job.data; const ctx = Logger.createContext(event.requestId); - let savedBallot; - if (event.isBallotUpdate) { - savedBallot = await BallotModel.updateBallot(event.inputBallot, ctx, `User updates a ballot`); - } else { - savedBallot = await BallotModel.getBallotByID(event.inputBallot.ballot_id, ctx); - if (!savedBallot){ - savedBallot = await BallotModel.submitBallot(event.inputBallot, ctx, `User submits a ballot`); - } - } - - if (event.roll != null) { - await ElectionRollModel.update(event.roll, ctx, `User submits a ballot`); - } if (event.userEmail) { const targetElection = await ElectionsModel.getElectionByID(event.inputBallot.election_id, ctx); if (targetElection == null){ throw new InternalServerError("Target Election null: " + ctx.contextId); } + const savedBallot = await BallotModel.getBallotByID(event.inputBallot.ballot_id, ctx); + if (!savedBallot) { + throw new InternalServerError("Ballot not found: " + event.inputBallot.ballot_id); + } const url = ServiceLocator.globalData().mainUrl; const receipt = Receipt(targetElection, event.userEmail, savedBallot, url, event.roll) await EmailService.sendEmails([receipt]) diff --git a/packages/backend/src/Controllers/Election/elections.controllers.ts b/packages/backend/src/Controllers/Election/elections.controllers.ts index 7ada1dae5..780a4480d 100644 --- a/packages/backend/src/Controllers/Election/elections.controllers.ts +++ b/packages/backend/src/Controllers/Election/elections.controllers.ts @@ -38,7 +38,7 @@ const electionExistsByID = async (req: any, res: any, next: any) => { res.json({ exists: await ElectionsModel.electionExistsByID(req.params._id, req) }) } -const electionSpecificAuth = async (req: IRequest, res: any, next: any) => { +const electionSpecificAuth = async (req: IElectionRequest, res: any, next: any) => { if (!req.election){ return next(); } diff --git a/packages/backend/src/Migrations/2026_03_05_unique_head_constraint.ts b/packages/backend/src/Migrations/2026_03_05_unique_head_constraint.ts new file mode 100644 index 000000000..59ff83379 --- /dev/null +++ b/packages/backend/src/Migrations/2026_03_05_unique_head_constraint.ts @@ -0,0 +1,9 @@ +import { Kysely, sql } from 'kysely' + +export async function up(db: Kysely): Promise { + await sql`CREATE UNIQUE INDEX "electionRollDB_one_head" ON "electionRollDB" (election_id, voter_id) WHERE head = true`.execute(db) +} + +export async function down(db: Kysely): Promise { + await sql`DROP INDEX "electionRollDB_one_head"`.execute(db) +} diff --git a/packages/backend/src/Models/Ballots.ts b/packages/backend/src/Models/Ballots.ts index 482f30b59..eb1d1ae13 100644 --- a/packages/backend/src/Models/Ballots.ts +++ b/packages/backend/src/Models/Ballots.ts @@ -36,10 +36,10 @@ export default class BallotsDB implements IBallotStore { return this.makeSubmitBallotsQuery(ballot, ctx, reason, db) as Promise } - async updateBallot(ballot: Ballot, ctx: ILoggingContext, reason: string): Promise { + async updateBallot(ballot: Ballot, ctx: ILoggingContext, reason: string, db?: Kysely | Transaction): Promise { Logger.debug(ctx, `${tableName}.update`); - return this._postgresClient.transaction().execute( async (tx) => { - const update_response = await tx.updateTable(tableName) + const executeWork = async (activeDb: Kysely | Transaction) => { + await activeDb.updateTable(tableName) .where('ballot_id', '=', ballot.ballot_id) .where('election_id', '=', ballot.election_id) .where('head', '=', true) @@ -47,13 +47,19 @@ export default class BallotsDB implements IBallotStore { //TODO: replace with DB trigger .set('update_date', Date.now().toString()) .execute(); - return this.submitBallot(ballot, ctx, reason, tx); - }); + return this.submitBallot(ballot, ctx, reason, activeDb); + }; + + if (db) { + return await executeWork(db); + } else { + return await this._postgresClient.transaction().execute(executeWork); + } } - bulkSubmitBallots(ballots: Ballot[], ctx: ILoggingContext, reason: string): Promise { + bulkSubmitBallots(ballots: Ballot[], ctx: ILoggingContext, reason: string, db?: Kysely | Transaction): Promise { Logger.debug(ctx, `${tableName}.bulkSubmit`) // removed ballot to make logging less noisy - return this.makeSubmitBallotsQuery(ballots, ctx, reason) as Promise + return this.makeSubmitBallotsQuery(ballots, ctx, reason, db) as Promise } private makeSubmitBallotsQuery(inputBallots: Ballot | Ballot[], ctx: ILoggingContext, reason: string, @@ -94,10 +100,11 @@ export default class BallotsDB implements IBallotStore { } - getBallotsByElectionID(election_id: string, ctx: ILoggingContext): Promise { + getBallotsByElectionID(election_id: string, ctx: ILoggingContext, db?: Kysely | Transaction): Promise { Logger.debug(ctx, `${tableName}.getBallotsByElectionID ${election_id}`); + const client = db || this._postgresClient; - return this._postgresClient + return client .selectFrom(tableName) .selectAll() .where('election_id', '=', election_id) @@ -105,10 +112,11 @@ export default class BallotsDB implements IBallotStore { .execute(); } - getBallotByVoterID(voter_id: string, election_id: string, ctx: ILoggingContext): Promise { + getBallotByVoterID(voter_id: string, election_id: string, ctx: ILoggingContext, db?: Kysely | Transaction): Promise { Logger.debug(ctx, `${tableName}.getBallotByVoterID ${voter_id} ${election_id}`); + const client = db || this._postgresClient; - return this._postgresClient + return client .selectFrom(tableName) .innerJoin(electionRollTableName, (join) => join @@ -122,10 +130,11 @@ export default class BallotsDB implements IBallotStore { .executeTakeFirst(); } - deleteAllBallotsForElectionID(election_id: string, ctx: ILoggingContext): Promise { + deleteAllBallotsForElectionID(election_id: string, ctx: ILoggingContext, db?: Kysely | Transaction): Promise { Logger.debug(ctx, `${tableName}.deleteAllBallotsForElectionID ${election_id}`); + const client = db || this._postgresClient; - return this._postgresClient + return client .deleteFrom(tableName) .where('election_id', '=', election_id) .returningAll() @@ -134,10 +143,11 @@ export default class BallotsDB implements IBallotStore { .catch(() => false); } - delete(ballot_id: Uid, ctx: ILoggingContext, reason: string): Promise { + delete(ballot_id: Uid, ctx: ILoggingContext, reason: string, db?: Kysely | Transaction): Promise { Logger.debug(ctx, `${tableName}.delete ${ballot_id}`); + const client = db || this._postgresClient; - return this._postgresClient + return client .deleteFrom(tableName) .where('ballot_id', '=', ballot_id) .returningAll() diff --git a/packages/backend/src/Models/CastVoteStore.ts b/packages/backend/src/Models/CastVoteStore.ts index 854aaca7d..de1527f2b 100644 --- a/packages/backend/src/Models/CastVoteStore.ts +++ b/packages/backend/src/Models/CastVoteStore.ts @@ -2,69 +2,108 @@ import { Ballot } from "@equal-vote/star-vote-shared/domain_model/Ballot"; import { ElectionRoll } from "@equal-vote/star-vote-shared/domain_model/ElectionRoll"; import { ILoggingContext } from "../Services/Logging/ILogger"; import Logger from "../Services/Logging/Logger"; -var pgFormat = require("pg-format"); +import { Kysely } from "kysely"; +import { Database } from "./Database"; +import { Uid } from "@equal-vote/star-vote-shared/domain_model/Uid"; + +export type CastVoteEvent = { + requestId: Uid, + inputBallot: Ballot, + roll?: ElectionRoll, + userEmail?: string, + isBallotUpdate: boolean, +} export default class CastVoteStore { - _postgresClient; - _ballotTableName: string; - _rollTableName: string; + _db: Kysely; - constructor(postgresClient: any) { - this._postgresClient = postgresClient; - this._ballotTableName = "ballotDB"; - this._rollTableName = "electionRollDB"; + constructor(db: Kysely) { + this._db = db; } - submitBallot( - ballot: Ballot, - roll: ElectionRoll, - ctx: ILoggingContext, - reason: String - ): Promise { - var ballotValues = [ - ballot.ballot_id, - ballot.election_id, - ballot.user_id, - ballot.status, - ballot.date_submitted, - JSON.stringify(ballot.votes), - JSON.stringify(ballot.history), - ballot.precinct, - ]; + // Postgres error code for unique_violation + private readonly POSTGRES_UNIQUE_VIOLATION = '23505'; - const ballotSQL = pgFormat( - `INSERT INTO ${this._ballotTableName} (ballot_id,election_id,user_id,status,date_submitted,ip_hash,votes,history,precinct) - VALUES (%L);`, - ballotValues - ); + async submitBallotEvent(event: CastVoteEvent, ctx: ILoggingContext): Promise { + return this._db.transaction().execute(async (trx) => { + if (event.inputBallot.user_id && !event.isBallotUpdate) { + const duplicateBallot = await trx.selectFrom('ballotDB') + .select(['ballot_id']) + .where('election_id', '=', event.inputBallot.election_id) + .where('user_id', '=', event.inputBallot.user_id) + .where('head', '=', true) + .executeTakeFirst(); + + if (duplicateBallot) { + Logger.info(ctx, `Duplicate ballot detected for roll-less election user_id: ${event.inputBallot.user_id}`); + } + } - var rollSql = pgFormat( - `UPDATE ${this._rollTableName} SET ballot_id=%L, submitted=%L, state=%L, history=%L, registration=%L WHERE election_id=%L AND voter_id=%L;`, - roll.ballot_id, - roll.submitted, - roll.state, - JSON.stringify(roll.history), - JSON.stringify(roll.registration), - roll.election_id, - roll.voter_id, - ); - Logger.debug(ctx, rollSql); + const ballotToInsert = { ...event.inputBallot }; + ballotToInsert.update_date = Date.now().toString(); + ballotToInsert.head = true; + ballotToInsert.create_date = new Date().toISOString(); - const transactionSql = `BEGIN; ${ballotSQL} ${rollSql} COMMIT;`; - Logger.debug(ctx, transactionSql); + if (event.isBallotUpdate) { + const updateBallotResult = await trx.updateTable('ballotDB') + .where('ballot_id', '=', ballotToInsert.ballot_id) + .where('election_id', '=', ballotToInsert.election_id) + .where('head', '=', true) + .set('head', false) + .execute(); + + if (Number(updateBallotResult[0].numUpdatedRows) === 0) { + throw new Error("CONCURRENT_BALLOT_UPDATE_DETECTED"); + } + + Logger.debug(ctx, `User updates a ballot`); + } else { + Logger.debug(ctx, `User submits a ballot`); + } - var p = this._postgresClient.query({ - rowMode: "array", - text: transactionSql, - }); + await trx.insertInto('ballotDB') + .values(ballotToInsert) + .execute(); + + if (event.roll != null) { + const originalUpdateDate = event.roll.update_date; + event.roll.submitted = true; + event.roll.update_date = Date.now().toString(); + event.roll.head = true; + + const updateResult = await trx.updateTable('electionRollDB') + .where('election_id', '=', event.roll.election_id) + .where('voter_id', '=', event.roll.voter_id) + .where('head', '=', true) + .where('update_date', '=', originalUpdateDate.toString()) // Optimistic Concurrency Control check + .set('head', false) + .execute(); + + if (Number(updateResult[0].numUpdatedRows) === 0) { + const existingCount = await trx.selectFrom('electionRollDB') + .where('election_id', '=', event.roll.election_id) + .where('voter_id', '=', event.roll.voter_id) + .where('head', '=', true) + .select('voter_id') + .executeTakeFirst(); + + if (existingCount) { + throw new Error("CONCURRENT_ROLL_EDIT_DETECTED"); + } + } - return p.then((res: any) => { - Logger.state(ctx, `Ballot submitted`, { - ballot: ballot, - roll: roll, - reason: reason, - }); - return ballot; + await trx.insertInto('electionRollDB') + .values(event.roll) + .execute(); + + Logger.debug(ctx, `User submits a ballot`); + } + }).catch((e: any) => { + if (e?.code === this.POSTGRES_UNIQUE_VIOLATION && + (e?.constraint === 'electionRollDB_one_head' || e?.constraint === 'electionRollDB_pkey')) { + throw new Error("ALREADY_VOTED"); + } + throw e; }); } } diff --git a/packages/backend/src/Models/ElectionRolls.ts b/packages/backend/src/Models/ElectionRolls.ts index f30776bd2..600fbcf9f 100644 --- a/packages/backend/src/Models/ElectionRolls.ts +++ b/packages/backend/src/Models/ElectionRolls.ts @@ -1,7 +1,7 @@ import { ILoggingContext } from '../Services/Logging/ILogger'; import Logger from '../Services/Logging/Logger'; import { IElectionRollStore } from './IElectionRollStore'; -import { Expression, Kysely } from 'kysely' +import { Expression, Kysely, Transaction } from 'kysely' import { Database } from './Database'; import { ElectionRoll } from '@equal-vote/star-vote-shared/domain_model/ElectionRoll'; const tableName = 'electionRollDB'; @@ -148,28 +148,36 @@ export default class ElectionRollDB implements IElectionRollStore { })) } - update(election_roll: ElectionRoll, ctx: ILoggingContext, reason: string): Promise { + async update(election_roll: ElectionRoll, ctx: ILoggingContext, reason: string, db?: Kysely | Transaction): Promise { Logger.debug(ctx, `${tableName}.updateRoll`); Logger.debug(ctx, "", election_roll) election_roll.update_date = Date.now().toString() election_roll.head = true - // Transaction to insert updated roll and set old version's head to false - return this._postgresClient.transaction().execute(async (trx) => { - await trx.updateTable(tableName) + + const executeWork = async (activeDb: Kysely | Transaction) => { + await activeDb.updateTable(tableName) .where('election_id', '=', election_roll.election_id) .where('voter_id', '=', election_roll.voter_id) .where('head', '=', true) .set('head', false) .execute() - return await trx.insertInto(tableName) + return await activeDb.insertInto(tableName) .values(election_roll) .returningAll() .executeTakeFirstOrThrow() - }).catch((reason: any) => { + }; + + try { + if (db) { + return await executeWork(db); + } else { + return await this._postgresClient.transaction().execute(executeWork); + } + } catch (reason: any) { Logger.debug(ctx, ".get null"); return null; - }) + } } delete(election_roll: ElectionRoll, ctx: ILoggingContext, reason: string): Promise { diff --git a/packages/backend/src/Models/IBallotStore.ts b/packages/backend/src/Models/IBallotStore.ts index fafd818ba..a9e38b412 100644 --- a/packages/backend/src/Models/IBallotStore.ts +++ b/packages/backend/src/Models/IBallotStore.ts @@ -1,14 +1,16 @@ import { Ballot } from "@equal-vote/star-vote-shared/domain_model/Ballot"; import { Uid } from "@equal-vote/star-vote-shared/domain_model/Uid"; import { ILoggingContext } from "../Services/Logging/ILogger"; +import { Kysely, Transaction } from 'kysely'; +import { Database } from './Database'; export interface IBallotStore { - submitBallot: (ballot: Ballot, ctx:ILoggingContext, reason:string) => Promise; - updateBallot: (ballot: Ballot, ctx: ILoggingContext, reason: string) => Promise; - bulkSubmitBallots: (ballots: Ballot[], ctx:ILoggingContext, reason:string) => Promise; - getBallotByID: (ballot_id: string, ctx:ILoggingContext) => Promise; - getBallotsByElectionID: (election_id: string, ctx:ILoggingContext) => Promise; - getBallotByVoterID: (voter_id: string, election_id: string, ctx:ILoggingContext) => Promise; - delete(ballot_id: Uid, ctx:ILoggingContext, reason:string): Promise; - deleteAllBallotsForElectionID: (election_id: string, ctx:ILoggingContext) => Promise; + submitBallot: (ballot: Ballot, ctx: ILoggingContext, reason: string, db?: Kysely | Transaction) => Promise; + updateBallot: (ballot: Ballot, ctx: ILoggingContext, reason: string, db?: Kysely | Transaction) => Promise; + bulkSubmitBallots: (ballots: Ballot[], ctx: ILoggingContext, reason: string, db?: Kysely | Transaction) => Promise; + getBallotByID: (ballot_id: string, ctx: ILoggingContext, db?: Kysely | Transaction) => Promise; + getBallotsByElectionID: (election_id: string, ctx: ILoggingContext, db?: Kysely | Transaction) => Promise; + getBallotByVoterID: (voter_id: string, election_id: string, ctx: ILoggingContext, db?: Kysely | Transaction) => Promise; + delete(ballot_id: Uid, ctx: ILoggingContext, reason: string, db?: Kysely | Transaction): Promise; + deleteAllBallotsForElectionID: (election_id: string, ctx: ILoggingContext, db?: Kysely | Transaction) => Promise; } diff --git a/packages/backend/src/Models/IElectionRollStore.ts b/packages/backend/src/Models/IElectionRollStore.ts index 5a8744a48..904844a61 100644 --- a/packages/backend/src/Models/IElectionRollStore.ts +++ b/packages/backend/src/Models/IElectionRollStore.ts @@ -1,36 +1,44 @@ import { ElectionRoll } from "@equal-vote/star-vote-shared/domain_model/ElectionRoll"; import { ILoggingContext } from "../Services/Logging/ILogger"; +import { Kysely, Transaction } from 'kysely'; +import { Database } from './Database'; export interface IElectionRollStore { submitElectionRoll: ( electionRolls: ElectionRoll[], ctx: ILoggingContext, - reason: string + reason: string, + db?: Kysely | Transaction ) => Promise; getRollsByElectionID: ( election_id: string, - ctx: ILoggingContext + ctx: ILoggingContext, + db?: Kysely | Transaction ) => Promise; getByVoterID: ( election_id: string, voter_id: string, - ctx: ILoggingContext + ctx: ILoggingContext, + db?: Kysely | Transaction ) => Promise; getElectionRoll: ( - election_id: string, - voter_id: string|null, - email: string|null, - ip_hash: string|null, - ctx:ILoggingContext - ) => Promise; + election_id: string, + voter_id: string | null, + email: string | null, + ip_hash: string | null, + ctx: ILoggingContext, + db?: Kysely | Transaction + ) => Promise; update: ( election_roll: ElectionRoll, ctx: ILoggingContext, - reason: string + reason: string, + db?: Kysely | Transaction ) => Promise; delete: ( election_roll: ElectionRoll, ctx: ILoggingContext, - reason: string + reason: string, + db?: Kysely | Transaction ) => Promise; } diff --git a/packages/backend/src/Models/__mocks__/CastVoteStore.ts b/packages/backend/src/Models/__mocks__/CastVoteStore.ts index 6b0e74dcb..6dd64547f 100644 --- a/packages/backend/src/Models/__mocks__/CastVoteStore.ts +++ b/packages/backend/src/Models/__mocks__/CastVoteStore.ts @@ -14,11 +14,24 @@ export default class CastVoteStore { this._rollStore = rollStore; } - async submitBallot(ballot: Ballot, roll:ElectionRoll, ctx:ILoggingContext, reason:string): Promise { + async submitBallotEvent(event: any, ctx: ILoggingContext): Promise { + if (event.roll) { + const currentRoll = await this._rollStore.getByVoterID(event.roll.election_id, event.roll.voter_id, ctx); + if (currentRoll && currentRoll.submitted && !event.isBallotUpdate) { + throw new Error("ALREADY_VOTED"); + } + } - const savedBallot = await this._ballotStore.submitBallot(ballot, ctx, reason); - await this._rollStore.update(roll, ctx, reason); - return savedBallot; + if (event.isBallotUpdate) { + await this._ballotStore.updateBallot(event.inputBallot, ctx, `User updates a ballot`); + } else { + await this._ballotStore.submitBallot(event.inputBallot, ctx, `User submits a ballot`); + } + + if (event.roll) { + event.roll.submitted = true; + await this._rollStore.update(event.roll, ctx, `User submits a ballot`); + } } } \ No newline at end of file diff --git a/packages/backend/src/Models/__mocks__/ElectionRolls.ts b/packages/backend/src/Models/__mocks__/ElectionRolls.ts index e3a70bf97..8474293de 100644 --- a/packages/backend/src/Models/__mocks__/ElectionRolls.ts +++ b/packages/backend/src/Models/__mocks__/ElectionRolls.ts @@ -67,7 +67,7 @@ export default class ElectionRollDB implements IElectionRollStore{ return Promise.resolve(res) } - update(voter_roll: ElectionRoll, ctx:ILoggingContext,reason:string): Promise { + update(voter_roll: ElectionRoll, ctx: ILoggingContext, reason: string, db?: any): Promise { Logger.debug(ctx, `MockElectionRolls update ${JSON.stringify(voter_roll)}`); const index = this._electionRolls.findIndex(electionRoll => { var electionMatch = electionRoll.election_id===voter_roll.election_id; diff --git a/packages/backend/src/ServiceLocator.ts b/packages/backend/src/ServiceLocator.ts index 7a373c9fe..e731f5c85 100644 --- a/packages/backend/src/ServiceLocator.ts +++ b/packages/backend/src/ServiceLocator.ts @@ -127,7 +127,7 @@ function electionRollDb(): ElectionRollDB { function castVoteStore(): CastVoteStore { if (_castVoteStore == null) { - _castVoteStore = new CastVoteStore(postgres()); + _castVoteStore = new CastVoteStore(database()); } return _castVoteStore; } diff --git a/packages/frontend/src/components/Election/Admin/ViewBallots.tsx b/packages/frontend/src/components/Election/Admin/ViewBallots.tsx index 62a9861f1..1b0671ed5 100644 --- a/packages/frontend/src/components/Election/Admin/ViewBallots.tsx +++ b/packages/frontend/src/components/Election/Admin/ViewBallots.tsx @@ -64,7 +64,7 @@ const ViewBallots = () => { {flags.isSet('VOTER_FLAGGING') && {ballot.precinct || ''} } - {ballot.status.toString()} + {ballot.status?.toString() || 'submitted'} {ballot.votes.map((vote) => ( vote.scores.map((score) => ( {score.score || ''}