Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Erasmus destination vote #133

Merged
merged 3 commits into from
Mar 25, 2024
Merged
Show file tree
Hide file tree
Changes from all 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
6 changes: 5 additions & 1 deletion back-end/deploy/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ const apiResources: ResourceController[] = [
{ name: 'speakers', paths: ['/speakers', '/speakers/{speakerId}'] },
{ name: 'sessions', paths: ['/sessions', '/sessions/{sessionId}'] },
{ name: 'registrations', paths: ['/registrations', '/registrations/{sessionId}'] },
{ name: 'connections', paths: ['/connections', '/connections/{connectionId}'] }
{ name: 'connections', paths: ['/connections', '/connections/{connectionId}'] },
{ name: 'contests', paths: ['/contests', '/contests/{contestId}'] }
];

const tables: { [tableName: string]: DDBTable } = {
Expand Down Expand Up @@ -115,6 +116,9 @@ const tables: { [tableName: string]: DDBTable } = {
projectionType: DDB.ProjectionType.ALL
}
]
},
contests: {
PK: { name: 'contestId', type: DDB.AttributeType.STRING }
}
};

Expand Down
167 changes: 167 additions & 0 deletions back-end/src/handlers/contests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
///
/// IMPORTS
///

import { DynamoDB, HandledError, ResourceController } from 'idea-aws';

import { Contest } from '../models/contest.model';
import { User } from '../models/user.model';

///
/// CONSTANTS, ENVIRONMENT VARIABLES, HANDLER
///

const PROJECT = process.env.PROJECT;
const STAGE = process.env.STAGE;
const DDB_TABLES = { users: process.env.DDB_TABLE_users, contests: process.env.DDB_TABLE_contests };
const ddb = new DynamoDB();

export const handler = (ev: any, _: any, cb: any): Promise<void> => new ContestsRC(ev, cb).handleRequest();

///
/// RESOURCE CONTROLLER
///

class ContestsRC extends ResourceController {
user: User;
contest: Contest;

constructor(event: any, callback: any) {
super(event, callback, { resourceId: 'contestId' });
if (STAGE === 'prod') this.silentLambdaLogs(); // to make the vote anonymous
}

protected async checkAuthBeforeRequest(): Promise<void> {
try {
this.user = new User(await ddb.get({ TableName: DDB_TABLES.users, Key: { userId: this.principalId } }));
} catch (err) {
throw new HandledError('User not found');
}

if (!this.resourceId) return;

try {
this.contest = new Contest(
await ddb.get({ TableName: DDB_TABLES.contests, Key: { contestId: this.resourceId } })
);
} catch (err) {
throw new HandledError('Contest not found');
}
}

protected async getResource(): Promise<Contest> {
if (!this.user.permissions.canManageContents && !this.contest.publishedResults) delete this.contest.results;
return this.contest;
}

protected async putResource(): Promise<Contest> {
if (!this.user.permissions.canManageContents) throw new HandledError('Unauthorized');

const oldResource = new Contest(this.contest);
this.contest.safeLoad(this.body, oldResource);

return await this.putSafeResource();
}
private async putSafeResource(opts: { noOverwrite?: boolean } = {}): Promise<Contest> {
const errors = this.contest.validate();
if (errors.length) throw new HandledError(`Invalid fields: ${errors.join(', ')}`);

const putParams: any = { TableName: DDB_TABLES.contests, Item: this.contest };
if (opts.noOverwrite) putParams.ConditionExpression = 'attribute_not_exists(contestId)';
await ddb.put(putParams);

return this.contest;
}

protected async patchResource(): Promise<void> {
switch (this.body.action) {
case 'VOTE':
return await this.userVote(this.body.candidate);
case 'PUBLISH_RESULTS':
return await this.publishResults();
default:
throw new HandledError('Unsupported action');
}
}
private async userVote(candidateName: string): Promise<void> {
if (!this.contest.isVoteStarted() || this.contest.isVoteEnded()) throw new HandledError('Vote is not open');

if (this.user.isExternal()) throw new HandledError("Externals can't vote");
if (!this.user.spot?.paymentConfirmedAt) throw new HandledError("Can't vote without confirmed spot");

const candidateIndex = this.contest.candidates.findIndex(c => c.name === candidateName);
if (candidateIndex === -1) throw new HandledError('Candidate not found');

const candidateCountry = this.contest.candidates[candidateIndex].country;
if (candidateCountry && candidateCountry === this.user.sectionCountry)
throw new HandledError("Can't vote for your country");

const markUserContestVoted = {
TableName: DDB_TABLES.users,
Key: { userId: this.user.userId },
ConditionExpression: 'attribute_not_exists(votedInContests) OR NOT contains(votedInContests, :contestId)',
UpdateExpression: 'SET votedInContests = list_append(if_not_exists(votedInContests, :emptyArr), :contestList)',
ExpressionAttributeValues: {
':contestId': this.contest.contestId,
':contestList': [this.contest.contestId],
':emptyArr': [] as string[]
}
};
const addUserVoteToContest = {
TableName: DDB_TABLES.contests,
Key: { contestId: this.contest.contestId },
UpdateExpression: `ADD results[${candidateIndex}] :one`,
ExpressionAttributeValues: { ':one': 1 }
};

await ddb.transactWrites([{ Update: markUserContestVoted }, { Update: addUserVoteToContest }]);
}
private async publishResults(): Promise<void> {
if (!this.user.permissions.canManageContents) throw new HandledError('Unauthorized');

if (this.contest.publishedResults) throw new HandledError('Already public');

if (!this.contest.isVoteEnded()) throw new HandledError('Vote is not done');

await ddb.update({
TableName: DDB_TABLES.contests,
Key: { contestId: this.contest.contestId },
UpdateExpression: 'SET publishedResults = :true',
ExpressionAttributeValues: { ':true': true }
});
}

protected async deleteResource(): Promise<void> {
if (!this.user.permissions.canManageContents) throw new HandledError('Unauthorized');

await ddb.delete({ TableName: DDB_TABLES.contests, Key: { contestId: this.resourceId } });
}

protected async postResources(): Promise<Contest> {
if (!this.user.permissions.canManageContents) throw new HandledError('Unauthorized');

this.contest = new Contest(this.body);
this.contest.contestId = await ddb.IUNID(PROJECT);
this.contest.createdAt = new Date().toISOString();
this.contest.enabled = false;
delete this.contest.voteEndsAt;
this.contest.results = [];
this.contest.candidates.forEach((): number => this.contest.results.push(0));
this.contest.publishedResults = false;

return await this.putSafeResource({ noOverwrite: true });
}

protected async getResources(): Promise<Contest[]> {
let contests = (await ddb.scan({ TableName: DDB_TABLES.contests })).map(x => new Contest(x));

if (!this.user.permissions.canManageContents) {
contests = contests.filter(c => c.enabled);
contests.forEach(contest => {
if (!contest.publishedResults) delete contest.results;
});
}

return contests.sort((a, b): number => b.createdAt.localeCompare(a.createdAt));
}
}
129 changes: 129 additions & 0 deletions back-end/src/models/contest.model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { Resource, epochISOString } from 'idea-toolbox';

/**
* A contest to which users can vote in.
*/
export class Contest extends Resource {
/**
* The ID of the contest.
*/
contestId: string;
/**
* Timestamp of creation (for sorting).
*/
createdAt: epochISOString;
/**
* Whether the contest is enabled and therefore shown in the menu to everyone.
*/
enabled: boolean;
/**
* If set, the vote is active (users can vote), and it ends at the configured timestamp.
*/
voteEndsAt?: epochISOString;
/**
* Name of the contest.
*/
name: string;
/**
* Description of the contest.
*/
description: string;
/**
* The URI to the contest's main image.
*/
imageURI: string;
/**
* The candidates of the contest (vote ballots).
*/
candidates: ContestCandidate[];
/**
* The count of votes for each of the sorted candidates.
* Note: the order of the candidates list must not change after the vote is open.
* This attribute is not accessible to non-admin users until `publishedResults` is true.
*/
results?: number[];
/**
* Whether the results are published and hence visible to any users.
*/
publishedResults: boolean;

load(x: any): void {
super.load(x);
this.contestId = this.clean(x.contestId, String);
this.createdAt = this.clean(x.createdAt, t => new Date(t).toISOString(), new Date().toISOString());
this.enabled = this.clean(x.enabled, Boolean, false);
if (x.voteEndsAt) this.voteEndsAt = this.clean(x.voteEndsAt, t => new Date(t).toISOString());
else delete this.voteEndsAt;
this.name = this.clean(x.name, String);
this.description = this.clean(x.description, String);
this.imageURI = this.clean(x.imageURI, String);
this.candidates = this.cleanArray(x.candidates, c => new ContestCandidate(c));
this.results = [];
for (let i = 0; i < this.candidates.length; i++) this.results[i] = Number(x.results[i] ?? 0);
this.publishedResults = this.clean(x.publishedResults, Boolean, false);
}

safeLoad(newData: any, safeData: any): void {
super.safeLoad(newData, safeData);
this.contestId = safeData.contestId;
this.createdAt = safeData.createdAt;
if (safeData.isVoteStarted()) {
this.candidates = safeData.candidates;
this.results = safeData.results;
}
}

validate(): string[] {
const e = super.validate();
if (this.iE(this.name)) e.push('name');
if (this.iE(this.candidates)) e.push('candidates');
this.candidates.forEach((c, index): void => c.validate().forEach(ea => e.push(`candidates[${index}].${ea}`)));
return e;
}

/**
* Whether the vote is started.
*/
isVoteStarted(): boolean {
return !!this.voteEndsAt;
}
/**
* Whether the vote has started and ended.
*/
isVoteEnded(): boolean {
return this.isVoteStarted() && new Date().toISOString() > this.voteEndsAt;
}
}

/**
* A candidate in a contest.
*/
export class ContestCandidate extends Resource {
/**
* The name of the candidate.
*/
name: string;
/**
* An URL where to find more info about the candidate.
*/
url: string;
/**
* The country of the candidate.
* This is particularly important beacuse, if set, users can't vote for candidates of their own countries.
*/
country: string | null;

load(x: any): void {
super.load(x);
this.name = this.clean(x.name, String);
this.url = this.clean(x.url, String);
this.country = this.clean(x.country, String);
}

validate(): string[] {
const e = super.validate();
if (this.iE(this.name)) e.push('name');
if (this.url && this.iE(this.url, 'url')) e.push('url');
return e;
}
}
19 changes: 14 additions & 5 deletions back-end/src/models/user.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,11 @@ export class User extends Resource {
*/
socialMedia: SocialMedia;

/**
* The list of contests (IDs) the user voted in.
*/
votedInContests: string[];

load(x: any): void {
super.load(x);
this.userId = this.clean(x.userId, String);
Expand All @@ -99,10 +104,12 @@ export class User extends Resource {
this.registrationForm = x.registrationForm ?? {};
if (x.registrationAt) this.registrationAt = this.clean(x.registrationAt, t => new Date(t).toISOString());
if (x.spot) this.spot = new EventSpotAttached(x.spot);
this.socialMedia = {}
if (x.socialMedia?.instagram) this.socialMedia.instagram = this.clean(x.socialMedia.instagram, String)
if (x.socialMedia?.linkedIn) this.socialMedia.linkedIn = this.clean(x.socialMedia.linkedIn, String)
if (x.socialMedia?.twitter) this.socialMedia.twitter = this.clean(x.socialMedia.twitter, String)
this.socialMedia = {};
if (x.socialMedia?.instagram) this.socialMedia.instagram = this.clean(x.socialMedia.instagram, String);
if (x.socialMedia?.linkedIn) this.socialMedia.linkedIn = this.clean(x.socialMedia.linkedIn, String);
if (x.socialMedia?.twitter) this.socialMedia.twitter = this.clean(x.socialMedia.twitter, String);

this.votedInContests = this.cleanArray(x.votedInContests, String);
}

safeLoad(newData: any, safeData: any): void {
Expand All @@ -123,6 +130,8 @@ export class User extends Resource {
if (safeData.registrationForm) this.registrationForm = safeData.registrationForm;
if (safeData.registrationAt) this.registrationAt = safeData.registrationAt;
if (safeData.spot) this.spot = safeData.spot;

this.votedInContests = safeData.votedInContests;
}

validate(): string[] {
Expand Down Expand Up @@ -250,4 +259,4 @@ export interface SocialMedia {
instagram?: string;
linkedIn?: string;
twitter?: string;
}
}
Loading