Skip to content

Commit

Permalink
Merge pull request #96 from uatisdeproblem/development
Browse files Browse the repository at this point in the history
Development
  • Loading branch information
uatisdeproblem authored Apr 28, 2024
2 parents 90f73f6 + cb03e05 commit 99ad976
Show file tree
Hide file tree
Showing 29 changed files with 816 additions and 252 deletions.
4 changes: 2 additions & 2 deletions back-end/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion back-end/package.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"version": "1.9.0",
"version": "1.10.0",
"name": "back-end",
"scripts": {
"lint": "eslint --ext .ts",
Expand Down
3 changes: 2 additions & 1 deletion back-end/src/handlers/vote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ class VoteRC extends ResourceController {
throw new HandledError('Voting session not found');
}

if (!this.votingSession.isForm()) throw new HandledError('Not a form-type voting session');
if (!this.votingSession.isInProgress()) throw new HandledError('Voting session not in progress');
}

Expand Down Expand Up @@ -118,7 +119,7 @@ class VoteRC extends ResourceController {
UpdateExpression: 'SET #v = if_not_exists(#v, :zero) + :value',
ExpressionAttributeValues: { ':value': votingTicket.weight, ':zero': 0 }
};
if (!this.votingSession.isSecret) {
if (!this.votingSession.isSecret()) {
updateParams.UpdateExpression += ', voters = list_append(if_not_exists(voters, :emptyArr), :voters)';
updateParams.ExpressionAttributeValues[':voters'] = [votingTicket.voterName];
updateParams.ExpressionAttributeValues[':emptyArr'] = [] as string[];
Expand Down
56 changes: 45 additions & 11 deletions back-end/src/handlers/votingSessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ class VotingSessionsRC extends ResourceController {
delete this.votingSession.startsAt;
delete this.votingSession.endsAt;
delete this.votingSession.timezone;
delete this.votingSession.resultsPublished;
delete this.votingSession.results;
delete this.votingSession.participantVoters;

Expand Down Expand Up @@ -158,9 +159,11 @@ class VotingSessionsRC extends ResourceController {
case 'GET_VOTING_TOKEN':
return await this.getVotingTokenOfVoter(this.body.voterId);
case 'GET_RESULTS':
return await this.getVotingResults();
return await this.getFormVotingResults();
case 'PUBLISH_RESULTS':
return await this.publishVotingResults();
return await this.publishFormVotingResults();
case 'SET_RESULTS':
return await this.setImmediateVotingResults(this.body.results, this.body.participantVoters, this.body.publish);
case 'ARCHIVE':
return await this.manageArchive(true);
case 'UNARCHIVE':
Expand All @@ -172,6 +175,7 @@ class VotingSessionsRC extends ResourceController {
private async startVotingSession(endsAt: epochISOString, timezone: string): Promise<VotingSession> {
if (!this.votingSession.canUserManage(this.galaxyUser)) throw new HandledError('Unauthorized');

if (!this.votingSession.isForm()) throw new HandledError('Voting session is immediate');
if (this.votingSession.hasStarted()) throw new HandledError("Can't be changed after start");

this.votingSession.startsAt = new Date().toISOString();
Expand Down Expand Up @@ -322,9 +326,10 @@ class VotingSessionsRC extends ResourceController {
});
return this.votingSession;
}
private async getVotingResults(): Promise<VotingResults> {
private async getFormVotingResults(): Promise<VotingResults> {
if (!this.votingSession.canUserManage(this.galaxyUser)) throw new HandledError('Unauthorized');

if (!this.votingSession.isForm()) throw new HandledError('Session is immediate');
if (!this.votingSession.hasEnded()) throw new HandledError('Session has not ended');

const resultsForBallotOption = (
Expand All @@ -339,18 +344,18 @@ class VotingSessionsRC extends ResourceController {
this.votingSession.ballots.forEach((ballot, bIndex): void => {
votingResults[bIndex] = [...ballot.options, 'Abstain'].map((): { value: number; voters?: string[] } => ({
value: 0,
voters: this.votingSession.isSecret ? undefined : []
voters: this.votingSession.isSecret() ? undefined : []
}));
});
resultsForBallotOption.forEach(x => {
const { bIndex, oIndex } = x.getIndexesFromSK();
votingResults[bIndex][oIndex].value = x.value;
if (!this.votingSession.isSecret) votingResults[bIndex][oIndex].voters = x.voters ?? [];
if (!this.votingSession.isSecret()) votingResults[bIndex][oIndex].voters = x.voters ?? [];
});
votingResults.forEach(ballotResult => {
const totValue = ballotResult.reduce((tot, acc): number => (tot += acc.value), 0);
const absent: { value: number; voters?: string[] } = { value: 1 - totValue };
if (!this.votingSession.isSecret) {
if (!this.votingSession.isSecret()) {
const votersPresent = new Set(this.votingSession.participantVoters);
absent.voters = this.votingSession.voters.map(x => x.name).filter(x => !votersPresent.has(x));
}
Expand All @@ -359,17 +364,46 @@ class VotingSessionsRC extends ResourceController {

return votingResults;
}
private async publishVotingResults(): Promise<VotingSession> {
if (this.votingSession.results) throw new HandledError('Already public');
private async publishFormVotingResults(): Promise<VotingSession> {
if (!this.votingSession.canUserManage(this.galaxyUser)) throw new HandledError('Unauthorized');
if (!this.votingSession.isForm()) throw new HandledError('Session is immediate');
if (this.votingSession.resultsPublished) throw new HandledError('Already public');

this.votingSession.results = await this.getVotingResults();
this.votingSession.resultsPublished = true;
this.votingSession.results = await this.getFormVotingResults();

await ddb.update({
TableName: DDB_TABLES.votingSessions,
Key: { sessionId: this.votingSession.sessionId },
UpdateExpression: 'SET results = :results',
ExpressionAttributeValues: { ':results': this.votingSession.results }
UpdateExpression: 'SET results = :results, resultsPublished = :true',
ExpressionAttributeValues: { ':results': this.votingSession.results, ':true': true }
});

return this.votingSession;
}
private async setImmediateVotingResults(
results: VotingResults,
participantVoters: string[],
publish: boolean
): Promise<VotingSession> {
if (this.votingSession.isForm()) throw new HandledError('Session is form-like');
if (this.votingSession.resultsPublished) throw new HandledError('Already public');

this.votingSession.resultsPublished = !!publish;
this.votingSession.results = results;
this.votingSession.participantVoters = participantVoters;

await ddb.update({
TableName: DDB_TABLES.votingSessions,
Key: { sessionId: this.votingSession.sessionId },
UpdateExpression: 'SET resultsPublished = :publish, results = :results, participantVoters = :pv',
ExpressionAttributeValues: {
':publish': this.votingSession.resultsPublished,
':results': this.votingSession.results,
':pv': this.votingSession.participantVoters
}
});

return this.votingSession;
}
private async manageArchive(archive: boolean): Promise<VotingSession> {
Expand Down
77 changes: 61 additions & 16 deletions back-end/src/models/votingSession.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@ export class VotingSession extends Resource {
*/
description: string;
/**
* Whether the session is secret or public.
* The type of voting session.
*/
isSecret: boolean;
type: VotingSessionTypes;
/**
* Whether the voting is weighted or not.
*/
Expand Down Expand Up @@ -62,16 +62,23 @@ export class VotingSession extends Resource {
voters: Voter[];
/**
* The timestamp when the voting session started. If not set, or in the future, the session hasn't started yet.
* Only form-like sessions have a start and an end.
*/
startsAt: epochISOString | null;
/**
* The timestamp of end of the voting session. If set and past, the voting session has ended.
* Only form-like sessions have a start and an end.
*/
endsAt: epochISOString | null;
/**
* A timezone for the timestamps of start and end of the voting session.
*/
timezone: string;
/**
* Whether the results have been published.
* NOTE: for immediate sessions the result is public (though hidden from the UI) and immediate anyway.
*/
resultsPublished: boolean;
/**
* The results of the voting session, in case they are published.
*/
Expand All @@ -90,24 +97,34 @@ export class VotingSession extends Resource {
this.sessionId = this.clean(x.sessionId, String);
this.name = this.clean(x.name, String);
this.description = this.clean(x.description, String);
this.isSecret = this.clean(x.isSecret, Boolean, false);
this.type = this.clean(x.type, String, VotingSessionTypes.FORM_PUBLIC);
if (!x.type && x.isSecret) this.type = VotingSessionTypes.FORM_SECRET; // backwards compatibility prior #88
this.isWeighted = this.clean(x.isWeighted, Boolean, false);
this.event = x.event?.eventId ? new GAEventAttached(x.event) : null;
this.createdAt = this.clean(x.createdAt, d => new Date(d).toISOString(), new Date().toISOString());
if (x.updatedAt) this.updatedAt = this.clean(x.updatedAt, d => new Date(d).toISOString());
if (x.publishedSince) this.publishedSince = this.clean(x.publishedSince, d => new Date(d).toISOString());
else delete this.publishedSince;
this.ballots = this.cleanArray(x.ballots, b => new VotingBallot(b)).slice(0, 50);
this.voters = this.cleanArray(x.voters, v => new Voter(v, this)).slice(0, 1000);
this.startsAt = this.clean(x.startsAt, d => new Date(d).toISOString());
this.endsAt = this.clean(x.endsAt, d => new Date(d).toISOString());
this.timezone = this.clean(x.timezone, String);
if (this.type === VotingSessionTypes.ROLL_CALL) this.ballots = [];
else this.ballots = this.cleanArray(x.ballots, b => new VotingBallot(b)).slice(0, 50);
this.voters = this.cleanArray(x.voters, v => new Voter(v, this))
.slice(0, 1000)
.sort((a, b): number => a.name.localeCompare(b.name));
if (this.isForm()) {
this.startsAt = this.clean(x.startsAt, d => new Date(d).toISOString());
this.endsAt = this.clean(x.endsAt, d => new Date(d).toISOString());
this.timezone = this.clean(x.timezone, String);
}
this.scrutineersIds = this.cleanArray(x.scrutineersIds, String).map(x => x.toLowerCase());
if (!this.hasEnded()) {
if (this.isForm() && !this.hasEnded()) {
this.resultsPublished = false;
delete this.results;
delete this.participantVoters;
} else {
if (x.results) this.results = x.results;
if (x.results) {
this.resultsPublished = this.clean(x.resultsPublished, Boolean, false);
this.results = x.results;
} else this.resultsPublished = false;
if (x.participantVoters)
this.participantVoters = this.cleanArray(x.participantVoters, String)?.sort((a, b): number =>
a.localeCompare(b)
Expand All @@ -119,10 +136,13 @@ export class VotingSession extends Resource {
safeLoad(newData: any, safeData: any): void {
super.safeLoad(newData, safeData);
this.sessionId = safeData.sessionId;
this.type = safeData.type;
if (!this.type) this.type = safeData.isSecret ? VotingSessionTypes.FORM_SECRET : VotingSessionTypes.FORM_PUBLIC; // backwards compatibility prior #88
this.isWeighted = safeData.isWeighted;
this.createdAt = safeData.createdAt;
if (safeData.updatedAt) this.updatedAt = safeData.updatedAt;
this.startsAt = safeData.startsAt;
this.resultsPublished = safeData.resultsPublished;
if (safeData.results) this.results = safeData.results;
if (safeData.participantVoters) this.participantVoters = safeData.participantVoters;
if (safeData.archivedAt) this.archivedAt = safeData.archivedAt;
Expand All @@ -131,6 +151,7 @@ export class VotingSession extends Resource {
validate(checkIfReady = false): string[] {
const e = super.validate();
if (this.iE(this.name)) e.push('name');
if (!Object.values(VotingSessionTypes).includes(this.type)) e.push('type');
this.ballots.forEach((b, i): void => b.validate().forEach(ea => e.push(`ballots[${i}].${ea}`)));
if (this.ballots.length > 50) e.push('ballots');
this.voters.forEach((v, i): void => v.validate(this).forEach(ea => e.push(`voters[${i}].${ea}`)));
Expand All @@ -139,7 +160,7 @@ export class VotingSession extends Resource {
if (checkIfReady || this.startsAt) {
if (this.iE(this.publishedSince, 'date') || this.publishedSince > new Date().toISOString())
e.push('publishedSince');
if (this.iE(this.ballots)) e.push('ballots');
if (this.type !== VotingSessionTypes.ROLL_CALL && this.iE(this.ballots)) e.push('ballots');
if (this.iE(this.voters)) e.push('voters');
const votersIds = this.voters.map(x => x.id?.trim());
const votersNames = this.voters.map(x => x.name?.trim().toLowerCase());
Expand All @@ -156,6 +177,20 @@ export class VotingSession extends Resource {
return e;
}

/**
* Whether the voting session is secret.
*/
isSecret(): boolean {
return this.type === VotingSessionTypes.FORM_SECRET;
}

/**
* Whether the voting session happens through a form.
*/
isForm(): boolean {
return [VotingSessionTypes.FORM_PUBLIC, VotingSessionTypes.FORM_SECRET].includes(this.type);
}

/**
* Whether the voting session is a draft (hence visible only to administrators); otherwise, it's considered published.
*/
Expand Down Expand Up @@ -234,6 +269,16 @@ export class VotingSession extends Resource {
}
}

/**
* The types of voting sessions.
*/
export enum VotingSessionTypes {
FORM_PUBLIC = 'FORM_PUBLIC',
FORM_SECRET = 'FORM_SECRET',
IMMEDIATE = 'IMMEDIATE',
ROLL_CALL = 'ROLL_CALL'
}

/**
* A voting ballot.
*/
Expand Down Expand Up @@ -290,10 +335,10 @@ export class Voter extends Resource {
*/
name: string;
/**
* The email address to which the voting tokens will be sent.
* The email address to which the voting tokens will be sent (in case of form-type voting sessions).
* If not set, no email will be sent; without a voting link, the voter can't vote and will result absent.
*/
email: string;
email: string | null;
/**
* A number with high precision that represents the weight of the voter.
* If the vote is not weighted, it equals `null`.
Expand All @@ -304,7 +349,7 @@ export class Voter extends Resource {
super.load(x);
this.id = this.clean(x.id, String, Math.random().toString(36).slice(-7).toUpperCase());
this.name = this.clean(x.name, String);
this.email = this.clean(x.email, String);
if (votingSession.isForm()) this.email = this.clean(x.email, String);
if (votingSession.isWeighted) this.voteWeight = this.clean(x.voteWeight, w => Math.round(Number(w)));
else this.voteWeight = null;
}
Expand All @@ -313,7 +358,7 @@ export class Voter extends Resource {
const e = super.validate();
if (this.iE(this.id)) e.push('id');
if (this.iE(this.name)) e.push('name');
if (this.email && this.iE(this.email, 'email')) e.push('email');
if (votingSession.isForm() && this.email && this.iE(this.email, 'email')) e.push('email');
if (votingSession.isWeighted && (this.voteWeight < 1 || this.voteWeight > 999_999)) e.push('voteWeight');
return e;
}
Expand All @@ -325,6 +370,6 @@ export class Voter extends Resource {
export interface ExportableVoter {
Name: string;
'Voter Identifier': string;
Email: string;
Email?: string;
'Vote Weight'?: number;
}
12 changes: 11 additions & 1 deletion back-end/swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ openapi: 3.0.3

info:
title: ESN Assembly app
version: 1.9.0
version: 1.10.0
contact:
name: Matteo Carbone
email: [email protected]
Expand Down Expand Up @@ -2382,6 +2382,7 @@ paths:
CHECK_EARLY_END,
GET_RESULTS,
PUBLISH_RESULTS,
SET_RESULTS,
ARCHIVE,
UNARCHIVE
]
Expand All @@ -2397,6 +2398,15 @@ paths:
email:
type: string
description: (RESEND_VOTING_LINK)
results:
type: object
description: (SET_RESULTS)
participantVoters:
type: string
description: (SET_RESULTS)
publish:
type: boolean
description: (SET_RESULTS)
responses:
200:
$ref: '#/components/responses/OperationCompleted'
Expand Down
4 changes: 2 additions & 2 deletions front-end/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion front-end/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "esn-assembly",
"version": "1.9.0",
"version": "1.10.0",
"author": "Matteo Carbone",
"homepage": "https://matteocarbone.com/",
"scripts": {
Expand Down
Loading

0 comments on commit 99ad976

Please sign in to comment.