Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 45 additions & 29 deletions packages/backend/src/Controllers/Ballot/castVoteController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 = [];
}
Expand All @@ -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 ,
Expand All @@ -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,
}
}
Expand Down Expand Up @@ -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`;
Expand Down Expand Up @@ -241,6 +255,17 @@ 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");
}
throw e;
}

await (await EventQueue).publish(castVoteEventQueue, event);

if(io != null){ // necessary for tests
Expand All @@ -261,24 +286,15 @@ async function castVoteController(req: IElectionRequest, res: Response, next: Ne
async function handleCastVoteEvent(job: { id: string; data: CastVoteEvent; }):Promise<void> {
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])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Kysely, sql } from 'kysely'

export async function up(db: Kysely<any>): Promise<void> {
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<any>): Promise<void> {
await sql`DROP INDEX "electionRollDB_one_head"`.execute(db)
}
40 changes: 25 additions & 15 deletions packages/backend/src/Models/Ballots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,24 +36,30 @@ export default class BallotsDB implements IBallotStore {
return this.makeSubmitBallotsQuery(ballot, ctx, reason, db) as Promise<Ballot>
}

async updateBallot(ballot: Ballot, ctx: ILoggingContext, reason: string): Promise<Ballot> {
async updateBallot(ballot: Ballot, ctx: ILoggingContext, reason: string, db?: Kysely<Database> | Transaction<Database>): Promise<Ballot> {
Logger.debug(ctx, `${tableName}.update`);
return this._postgresClient.transaction().execute( async (tx) => {
const update_response = await tx.updateTable(tableName)
const executeWork = async (activeDb: Kysely<Database> | Transaction<Database>) => {
await activeDb.updateTable(tableName)
.where('ballot_id', '=', ballot.ballot_id)
.where('election_id', '=', ballot.election_id)
.where('head', '=', true)
.set('head', false)
//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<Ballot[]> {
bulkSubmitBallots(ballots: Ballot[], ctx: ILoggingContext, reason: string, db?: Kysely<Database> | Transaction<Database>): Promise<Ballot[]> {
Logger.debug(ctx, `${tableName}.bulkSubmit`) // removed ballot to make logging less noisy
return this.makeSubmitBallotsQuery(ballots, ctx, reason) as Promise<Ballot[]>
return this.makeSubmitBallotsQuery(ballots, ctx, reason, db) as Promise<Ballot[]>
}

private makeSubmitBallotsQuery(inputBallots: Ballot | Ballot[], ctx: ILoggingContext, reason: string,
Expand Down Expand Up @@ -94,21 +100,23 @@ export default class BallotsDB implements IBallotStore {
}


getBallotsByElectionID(election_id: string, ctx: ILoggingContext): Promise<Ballot[] | null> {
getBallotsByElectionID(election_id: string, ctx: ILoggingContext, db?: Kysely<Database> | Transaction<Database>): Promise<Ballot[] | null> {
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)
.where('head', '=', true)
.execute();
}

getBallotByVoterID(voter_id: string, election_id: string, ctx: ILoggingContext): Promise<Ballot | undefined> {
getBallotByVoterID(voter_id: string, election_id: string, ctx: ILoggingContext, db?: Kysely<Database> | Transaction<Database>): Promise<Ballot | undefined> {
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
Expand All @@ -122,10 +130,11 @@ export default class BallotsDB implements IBallotStore {
.executeTakeFirst();
}

deleteAllBallotsForElectionID(election_id: string, ctx: ILoggingContext): Promise<boolean> {
deleteAllBallotsForElectionID(election_id: string, ctx: ILoggingContext, db?: Kysely<Database> | Transaction<Database>): Promise<boolean> {
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()
Expand All @@ -134,10 +143,11 @@ export default class BallotsDB implements IBallotStore {
.catch(() => false);
}

delete(ballot_id: Uid, ctx: ILoggingContext, reason: string): Promise<boolean> {
delete(ballot_id: Uid, ctx: ILoggingContext, reason: string, db?: Kysely<Database> | Transaction<Database>): Promise<boolean> {
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()
Expand Down
Loading