diff --git a/.gitignore b/.gitignore
index 36052cc3..78548fd3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -41,4 +41,5 @@ back-end/output-config.json
back-end/src/**/*.js
back-end/src/**/*.js.map
front-end/.angular/cache
-front-end/resources
\ No newline at end of file
+front-end/resources
+scripts/src/**/*.js
\ No newline at end of file
diff --git a/back-end/deploy/main.ts b/back-end/deploy/main.ts
index a42ba754..23546ede 100755
--- a/back-end/deploy/main.ts
+++ b/back-end/deploy/main.ts
@@ -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 } = {
@@ -115,6 +116,9 @@ const tables: { [tableName: string]: DDBTable } = {
projectionType: DDB.ProjectionType.ALL
}
]
+ },
+ contests: {
+ PK: { name: 'contestId', type: DDB.AttributeType.STRING }
}
};
diff --git a/back-end/package-lock.json b/back-end/package-lock.json
index 3f052886..18bba1c3 100644
--- a/back-end/package-lock.json
+++ b/back-end/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "back-end",
- "version": "3.4.0",
+ "version": "3.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "back-end",
- "version": "3.4.0",
+ "version": "3.5.0",
"dependencies": {
"axios": "^1.6.7",
"date-fns": "^3.3.1",
diff --git a/back-end/package.json b/back-end/package.json
index ebfdb883..78001987 100644
--- a/back-end/package.json
+++ b/back-end/package.json
@@ -1,5 +1,5 @@
{
- "version": "3.4.0",
+ "version": "3.5.0",
"name": "back-end",
"scripts": {
"lint": "eslint --ext .ts",
diff --git a/back-end/src/handlers/contests.ts b/back-end/src/handlers/contests.ts
new file mode 100644
index 00000000..71d2bd43
--- /dev/null
+++ b/back-end/src/handlers/contests.ts
@@ -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 => 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 {
+ 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 {
+ if (!this.user.permissions.canManageContents && !this.contest.publishedResults) delete this.contest.results;
+ return this.contest;
+ }
+
+ protected async putResource(): Promise {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ if (!this.user.permissions.canManageContents) throw new HandledError('Unauthorized');
+
+ await ddb.delete({ TableName: DDB_TABLES.contests, Key: { contestId: this.resourceId } });
+ }
+
+ protected async postResources(): Promise {
+ 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 {
+ 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));
+ }
+}
diff --git a/back-end/src/handlers/registrations.ts b/back-end/src/handlers/registrations.ts
index 83520f9c..f9e197f0 100644
--- a/back-end/src/handlers/registrations.ts
+++ b/back-end/src/handlers/registrations.ts
@@ -5,7 +5,7 @@
import { DynamoDB, HandledError, ResourceController } from 'idea-aws';
import { Session } from '../models/session.model';
-import { SessionRegistration } from '../models/sessionRegistration.model';
+import { SessionRegistration, SessionRegistrationExportable } from '../models/sessionRegistration.model';
import { User } from '../models/user.model';
import { Configurations } from '../models/configurations.model';
@@ -23,13 +23,13 @@ const DDB_TABLES = {
const ddb = new DynamoDB();
-export const handler = (ev: any, _: any, cb: any) => new SessionRegistrations(ev, cb).handleRequest();
+export const handler = (ev: any, _: any, cb: any): Promise => new SessionRegistrationsRC(ev, cb).handleRequest();
///
/// RESOURCE CONTROLLER
///
-class SessionRegistrations extends ResourceController {
+class SessionRegistrationsRC extends ResourceController {
user: User;
configurations: Configurations;
registration: SessionRegistration;
@@ -39,10 +39,11 @@ class SessionRegistrations extends ResourceController {
}
protected async checkAuthBeforeRequest(): Promise {
-
+ const sessionId = this.resourceId;
+ const userId = this.principalId;
try {
- this.user = new User(await ddb.get({ TableName: DDB_TABLES.users, Key: { userId: this.principalId } }));
+ this.user = new User(await ddb.get({ TableName: DDB_TABLES.users, Key: { userId } }));
} catch (err) {
throw new HandledError('User not found');
}
@@ -55,39 +56,31 @@ class SessionRegistrations extends ResourceController {
throw new HandledError('Configuration not found');
}
- if (!this.resourceId || this.httpMethod === 'POST') return;
+ if (!sessionId || this.httpMethod === 'POST') return;
try {
this.registration = new SessionRegistration(
- await ddb.get({
- TableName: DDB_TABLES.registrations,
- Key: { sessionId: this.resourceId, userId: this.principalId }
- })
+ await ddb.get({ TableName: DDB_TABLES.registrations, Key: { sessionId, userId } })
);
} catch (err) {
throw new HandledError('Registration not found');
}
}
- protected async getResources(): Promise {
+ protected async getResources(): Promise {
if (this.queryParams.sessionId) {
- try {
- const registrationsOfSession = await ddb.query({
- TableName: DDB_TABLES.registrations,
- KeyConditionExpression: 'sessionId = :sessionId',
- ExpressionAttributeValues: { ':sessionId': this.queryParams.sessionId }
- });
- return registrationsOfSession.map(s => new SessionRegistration(s));
- } catch (error) {
- throw new HandledError('Could not load registrations for this session');
- }
+ if (this.queryParams.export && this.user.permissions.canManageContents)
+ return await this.getExportableSessionRegistrations(this.queryParams.sessionId);
+ else return this.getRegistrationsOfSessionById(this.queryParams.sessionId);
} else {
- return await this.getUsersRegistrations(this.principalId);
+ if (this.queryParams.export && this.user.permissions.canManageContents)
+ return await this.getExportableSessionRegistrations();
+ else return await this.getRegistrationsOfUserById(this.principalId);
}
}
protected async postResource(): Promise {
- if (!this.configurations.areSessionRegistrationsOpen) throw new HandledError('Registrations are closed!')
+ if (!this.configurations.areSessionRegistrationsOpen) throw new HandledError('Registrations are closed!');
this.registration = new SessionRegistration({
sessionId: this.resourceId,
@@ -105,84 +98,70 @@ class SessionRegistrations extends ResourceController {
}
protected async deleteResource(): Promise {
- if (!this.configurations.areSessionRegistrationsOpen) throw new HandledError('Registrations are closed!')
+ if (!this.configurations.areSessionRegistrationsOpen) throw new HandledError('Registrations are closed!');
- try {
- const { sessionId, userId } = this.registration;
-
- const deleteSessionRegistration = { TableName: DDB_TABLES.registrations, Key: { sessionId, userId } };
-
- const updateSessionCount = {
- TableName: DDB_TABLES.sessions,
- Key: { sessionId },
- UpdateExpression: 'ADD numberOfParticipants :minusOne',
- ExpressionAttributeValues: {
- ':minusOne': -1
- }
- };
-
- const removeFromFavorites = {
- TableName: DDB_TABLES.usersFavoriteSessions,
- Key: { userId: this.principalId, sessionId }
- };
-
- await ddb.transactWrites([
- { Delete: deleteSessionRegistration },
- { Delete: removeFromFavorites },
- { Update: updateSessionCount }
- ]);
- } catch (err) {
- throw new HandledError('Delete failed');
- }
+ const { sessionId, userId } = this.registration;
+
+ const deleteSessionRegistration = { TableName: DDB_TABLES.registrations, Key: { sessionId, userId } };
+
+ const updateSessionCount = {
+ TableName: DDB_TABLES.sessions,
+ Key: { sessionId },
+ UpdateExpression: 'ADD numberOfParticipants :minusOne',
+ ExpressionAttributeValues: { ':minusOne': -1 }
+ };
+
+ const removeFromFavorites = {
+ TableName: DDB_TABLES.usersFavoriteSessions,
+ Key: { userId: this.principalId, sessionId }
+ };
+
+ await ddb.transactWrites([
+ { Delete: deleteSessionRegistration },
+ { Delete: removeFromFavorites },
+ { Update: updateSessionCount }
+ ]);
}
private async putSafeResource(): Promise {
const { sessionId, userId } = this.registration;
- const session: Session = new Session(await ddb.get({ TableName: DDB_TABLES.sessions, Key: { sessionId } }));
+ const session = await this.getSessionById(sessionId);
const isValid = await this.validateRegistration(session, userId);
if (!isValid) throw new HandledError("User can't sign up for this session!");
- try {
- const putSessionRegistration = { TableName: DDB_TABLES.registrations, Item: this.registration };
-
- const updateSessionCount = {
- TableName: DDB_TABLES.sessions,
- Key: { sessionId },
- UpdateExpression: 'ADD numberOfParticipants :one',
- ConditionExpression: 'numberOfParticipants < :limit',
- ExpressionAttributeValues: {
- ':one': 1,
- ":limit": session.limitOfParticipants
- }
- };
-
- const addToFavorites = {
- TableName: DDB_TABLES.usersFavoriteSessions,
- Item: { userId: this.principalId, sessionId: this.resourceId }
- }
-
- await ddb.transactWrites([
- { Put: putSessionRegistration },
- { Put: addToFavorites },
- { Update: updateSessionCount }
- ]);
-
- return this.registration;
- } catch (err) {
- throw new HandledError('Operation failed');
- }
+ const putSessionRegistration = { TableName: DDB_TABLES.registrations, Item: this.registration };
+
+ const updateSessionCount = {
+ TableName: DDB_TABLES.sessions,
+ Key: { sessionId },
+ UpdateExpression: 'ADD numberOfParticipants :one',
+ ConditionExpression: 'numberOfParticipants < :limit',
+ ExpressionAttributeValues: { ':one': 1, ':limit': session.limitOfParticipants }
+ };
+
+ const addToFavorites = {
+ TableName: DDB_TABLES.usersFavoriteSessions,
+ Item: { userId: this.principalId, sessionId: this.resourceId }
+ };
+
+ await ddb.transactWrites([
+ { Put: putSessionRegistration },
+ { Put: addToFavorites },
+ { Update: updateSessionCount }
+ ]);
+
+ return this.registration;
}
- private async validateRegistration(session: Session, userId: string) {
+ private async validateRegistration(session: Session, userId: string): Promise {
if (!session.requiresRegistration) throw new HandledError("User can't sign up for this session!");
if (session.isFull()) throw new HandledError('Session is full! Refresh your page.');
- const userRegistrations: SessionRegistration[] = await this.getUsersRegistrations(userId);
-
+ const userRegistrations = await this.getRegistrationsOfUserById(userId);
if (!userRegistrations.length) return true;
- const sessions: Session[] = (
+ const sessions = (
await ddb.batchGet(
DDB_TABLES.sessions,
userRegistrations.map(ur => ({ sessionId: ur.sessionId }))
@@ -213,9 +192,9 @@ class SessionRegistrations extends ResourceController {
return true;
}
- private async getUsersRegistrations(userId: string): Promise {
+ private async getRegistrationsOfUserById(userId: string): Promise {
try {
- const registrationsOfUser = await ddb.query({
+ const registrationsOfUser: SessionRegistration[] = await ddb.query({
TableName: DDB_TABLES.registrations,
IndexName: 'userId-sessionId-index',
KeyConditionExpression: 'userId = :userId',
@@ -226,4 +205,46 @@ class SessionRegistrations extends ResourceController {
throw new HandledError('Could not load registrations for this user');
}
}
+ private async getRegistrationsOfSessionById(sessionId: string): Promise {
+ try {
+ const registrationsOfSession: SessionRegistration[] = await ddb.query({
+ TableName: DDB_TABLES.registrations,
+ KeyConditionExpression: 'sessionId = :sessionId',
+ ExpressionAttributeValues: { ':sessionId': sessionId }
+ });
+ return registrationsOfSession.map(s => new SessionRegistration(s));
+ } catch (error) {
+ throw new HandledError('Could not load registrations for this session');
+ }
+ }
+ private async getSessionById(sessionId: string): Promise {
+ try {
+ return new Session(await ddb.get({ TableName: DDB_TABLES.sessions, Key: { sessionId } }));
+ } catch (err) {
+ throw new HandledError('Session not found');
+ }
+ }
+ private async getExportableSessionRegistrations(sessionId?: string): Promise {
+ let sessions: Session[], registrations: SessionRegistration[];
+
+ if (sessionId) {
+ sessions = [await this.getSessionById(sessionId)];
+ registrations = await this.getRegistrationsOfSessionById(sessionId);
+ } else {
+ sessions = (await ddb.scan({ TableName: DDB_TABLES.sessions })).map(x => new Session(x));
+ registrations = (await ddb.scan({ TableName: DDB_TABLES.registrations })).map(x => new SessionRegistration(x));
+ }
+
+ const list: SessionRegistrationExportable[] = [];
+ sessions.map(session =>
+ list.push(
+ ...SessionRegistration.export(
+ session,
+ registrations.filter(r => r.sessionId === session.sessionId)
+ )
+ )
+ );
+
+ return list;
+ }
}
diff --git a/back-end/src/handlers/sessions.ts b/back-end/src/handlers/sessions.ts
index bd84097d..7f5c0e45 100644
--- a/back-end/src/handlers/sessions.ts
+++ b/back-end/src/handlers/sessions.ts
@@ -8,29 +8,29 @@ import { Session } from '../models/session.model';
import { SpeakerLinked } from '../models/speaker.model';
import { RoomLinked } from '../models/room.model';
import { User } from '../models/user.model';
+import { SessionRegistration } from '../models/sessionRegistration.model';
///
/// CONSTANTS, ENVIRONMENT VARIABLES, HANDLER
///
const PROJECT = process.env.PROJECT;
-
const DDB_TABLES = {
users: process.env.DDB_TABLE_users,
sessions: process.env.DDB_TABLE_sessions,
rooms: process.env.DDB_TABLE_rooms,
- speakers: process.env.DDB_TABLE_speakers
+ speakers: process.env.DDB_TABLE_speakers,
+ registrations: process.env.DDB_TABLE_registrations
};
-
const ddb = new DynamoDB();
-export const handler = (ev: any, _: any, cb: any) => new Sessions(ev, cb).handleRequest();
+export const handler = (ev: any, _: any, cb: any): Promise => new SessionsRC(ev, cb).handleRequest();
///
/// RESOURCE CONTROLLER
///
-class Sessions extends ResourceController {
+class SessionsRC extends ResourceController {
user: User;
session: Session;
@@ -57,6 +57,10 @@ class Sessions extends ResourceController {
}
protected async getResource(): Promise {
+ if (!this.user.permissions.canManageContents || !this.user.permissions.isAdmin) {
+ delete this.session.feedbackResults;
+ delete this.session.feedbackComments;
+ }
return this.session;
}
@@ -73,12 +77,11 @@ class Sessions extends ResourceController {
await ddb.get({ TableName: DDB_TABLES.rooms, Key: { roomId: this.session.room.roomId } })
);
- const getSpeakers = await ddb.batchGet(
+ const getSpeakers: SpeakerLinked[] = await ddb.batchGet(
DDB_TABLES.speakers,
this.session.speakers?.map(s => ({ speakerId: s.speakerId })),
true
- )
-
+ );
this.session.speakers = getSpeakers.map(s => new SpeakerLinked(s));
const errors = this.session.validate();
@@ -95,16 +98,69 @@ class Sessions extends ResourceController {
}
}
- protected async deleteResource(): Promise {
- if (!this.user.permissions.canManageContents) throw new HandledError('Unauthorized');
+ protected async patchResource(): Promise {
+ switch (this.body.action) {
+ case 'GIVE_FEEDBACK':
+ return await this.userFeedback(this.body.rating, this.body.comment);
+ default:
+ throw new HandledError('Unsupported action');
+ }
+ }
+ private async userFeedback(rating: number, comment?: string): Promise {
+ let sessionRegistration: SessionRegistration;
try {
- await ddb.delete({ TableName: DDB_TABLES.sessions, Key: { sessionId: this.resourceId } });
- } catch (err) {
- throw new HandledError('Delete failed');
+ sessionRegistration = new SessionRegistration(
+ await ddb.get({
+ TableName: DDB_TABLES.registrations,
+ Key: { sessionId: this.session.sessionId, userId: this.user.userId }
+ })
+ );
+ } catch (error) {
+ throw new HandledError("Can't rate a session without being registered");
+ }
+
+ if (!sessionRegistration) throw new HandledError("Can't rate a session without being registered");
+
+ if (sessionRegistration.hasUserRated) throw new HandledError('Already rated this session');
+
+ if (new Date().toISOString() < this.session.endsAt)
+ throw new HandledError("Can't rate a session before it has ended");
+
+ if (rating < 1 || rating > 5 || !Number.isInteger(rating)) throw new HandledError('Invalid rating');
+
+ const addUserRatingToSession = {
+ TableName: DDB_TABLES.sessions,
+ Key: { sessionId: this.session.sessionId },
+ UpdateExpression: `ADD feedbackResults[${rating - 1}] :one`,
+ ExpressionAttributeValues: { ':one': 1 }
+ };
+
+ const setHasUserRated = {
+ TableName: DDB_TABLES.registrations,
+ Key: { sessionId: this.session.sessionId, userId: this.user.userId },
+ UpdateExpression: 'SET hasUserRated = :true',
+ ExpressionAttributeValues: { ':true': true }
+ };
+
+ await ddb.transactWrites([{ Update: addUserRatingToSession }, { Update: setHasUserRated }]);
+
+ if (comment) {
+ await ddb.update({
+ TableName: DDB_TABLES.sessions,
+ Key: { sessionId: this.session.sessionId },
+ UpdateExpression: 'SET feedbackComments = list_append(feedbackComments, :comment)',
+ ExpressionAttributeValues: { ':comment': [comment] }
+ });
}
}
+ protected async deleteResource(): Promise {
+ if (!this.user.permissions.canManageContents) throw new HandledError('Unauthorized');
+
+ await ddb.delete({ TableName: DDB_TABLES.sessions, Key: { sessionId: this.resourceId } });
+ }
+
protected async postResources(): Promise {
if (!this.user.permissions.canManageContents) throw new HandledError('Unauthorized');
@@ -115,20 +171,21 @@ class Sessions extends ResourceController {
}
protected async getResources(): Promise {
- try {
- const sessions = (await ddb.scan({ TableName: DDB_TABLES.sessions })).map((x: Session) => new Session(x));
-
- const filtertedSessions = sessions.filter(
- x =>
- (!this.queryParams.speaker || x.speakers.some(speaker => speaker.speakerId === this.queryParams.speaker)) &&
- (!this.queryParams.room || x.room.roomId === this.queryParams.room)
- );
+ const sessions = (await ddb.scan({ TableName: DDB_TABLES.sessions })).map(x => new Session(x));
- const sortedSessions = filtertedSessions.sort((a, b) => a.startsAt.localeCompare(b.startsAt));
+ const filteredSessions = sessions.filter(
+ x =>
+ (!this.queryParams.speaker || x.speakers.some(speaker => speaker.speakerId === this.queryParams.speaker)) &&
+ (!this.queryParams.room || x.room.roomId === this.queryParams.room)
+ );
- return sortedSessions;
- } catch (err) {
- throw new HandledError('Operation failed');
+ if (!this.user.permissions.canManageContents) {
+ filteredSessions.forEach(session => {
+ delete session.feedbackResults;
+ delete session.feedbackComments;
+ });
}
+
+ return filteredSessions.sort((a, b): number => a.startsAt.localeCompare(b.startsAt));
}
}
diff --git a/back-end/src/models/contest.model.ts b/back-end/src/models/contest.model.ts
new file mode 100644
index 00000000..5921cb3e
--- /dev/null
+++ b/back-end/src/models/contest.model.ts
@@ -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;
+ }
+}
diff --git a/back-end/src/models/session.model.ts b/back-end/src/models/session.model.ts
index 6d45f2e5..9b8793b6 100644
--- a/back-end/src/models/session.model.ts
+++ b/back-end/src/models/session.model.ts
@@ -8,6 +8,11 @@ import { SpeakerLinked } from './speaker.model';
*/
type datetime = string;
+/**
+ * The max number of stars you can give to a session.
+ */
+const MAX_RATING = 5;
+
export class Session extends Resource {
/**
* The session ID.
@@ -61,6 +66,15 @@ export class Session extends Resource {
* Wether the sessions requires registration.
*/
requiresRegistration: boolean;
+ /**
+ * The counts of each star rating given to the session as feedback.
+ * Indices 0-4 correspond to 1-5 star ratings.
+ */
+ feedbackResults?: number[];
+ /**
+ * A list of feedback comments from the participants.
+ */
+ feedbackComments?: string[];
load(x: any): void {
super.load(x);
@@ -81,6 +95,10 @@ export class Session extends Resource {
this.numberOfParticipants = this.clean(x.numberOfParticipants, Number, 0);
this.limitOfParticipants = this.clean(x.limitOfParticipants, Number);
}
+ this.feedbackResults = [];
+ for (let i = 0; i < MAX_RATING; i++)
+ this.feedbackResults[i] = x.feedbackResults ? Number(x.feedbackResults[i] ?? 0) : 0;
+ this.feedbackComments = this.cleanArray(x.feedbackComments, String);
}
safeLoad(newData: any, safeData: any): void {
super.safeLoad(newData, safeData);
@@ -116,15 +134,14 @@ export class Session extends Resource {
}
isFull(): boolean {
- return this.requiresRegistration ? this.numberOfParticipants >= this.limitOfParticipants : false
+ return this.requiresRegistration ? this.numberOfParticipants >= this.limitOfParticipants : false;
}
getSpeakers(): string {
- return this.speakers.map(s => s.name).join(', ')
+ return this.speakers.map(s => s.name).join(', ');
}
}
-
export enum SessionType {
DISCUSSION = 'DISCUSSION',
TALK = 'TALK',
diff --git a/back-end/src/models/sessionRegistration.model.ts b/back-end/src/models/sessionRegistration.model.ts
index eb1a6779..144986fb 100644
--- a/back-end/src/models/sessionRegistration.model.ts
+++ b/back-end/src/models/sessionRegistration.model.ts
@@ -1,5 +1,7 @@
import { Resource } from 'idea-toolbox';
+import { Session } from './session.model';
+
export class SessionRegistration extends Resource {
/**
* The session ID.
@@ -21,6 +23,10 @@ export class SessionRegistration extends Resource {
* The user's ESN Country if any.
*/
sectionCountry?: string;
+ /**
+ * Whether the user has rated the session.
+ */
+ hasUserRated: boolean;
load(x: any): void {
super.load(x);
@@ -29,5 +35,40 @@ export class SessionRegistration extends Resource {
this.registrationDateInMs = this.clean(x.registrationDateInMs, t => new Date(t).getTime());
this.name = this.clean(x.name, String);
if (x.sectionCountry) this.sectionCountry = this.clean(x.sectionCountry, String);
+ this.hasUserRated = this.clean(x.hasUserRated, Boolean);
}
+
+ /**
+ * Get the exportable version of the list of registrations for a session.
+ */
+ static export(session: Session, registrations: SessionRegistration[]): SessionRegistrationExportable[] {
+ if (!registrations.length)
+ return [
+ {
+ Session: session.name,
+ Code: session.code ?? null,
+ Type: session.type,
+ Participant: null,
+ 'ESN Country': null
+ }
+ ];
+ return registrations.map(r => ({
+ Session: session.name,
+ Code: session.code ?? null,
+ Type: session.type,
+ Participant: r.name,
+ 'ESN Country': r.sectionCountry ?? null
+ }));
+ }
+}
+
+/**
+ * An exportable version of a session registration.
+ */
+export interface SessionRegistrationExportable {
+ Session: string;
+ Code: string | null;
+ Type: string;
+ Participant: string | null;
+ 'ESN Country': string | null;
}
diff --git a/back-end/src/models/user.model.ts b/back-end/src/models/user.model.ts
index c4ea8b17..b5bcf7fa 100644
--- a/back-end/src/models/user.model.ts
+++ b/back-end/src/models/user.model.ts
@@ -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);
@@ -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 {
@@ -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[] {
@@ -250,4 +259,4 @@ export interface SocialMedia {
instagram?: string;
linkedIn?: string;
twitter?: string;
-}
\ No newline at end of file
+}
diff --git a/back-end/swagger.yaml b/back-end/swagger.yaml
index 4aa33208..9f9d37b6 100644
--- a/back-end/swagger.yaml
+++ b/back-end/swagger.yaml
@@ -2,7 +2,7 @@ openapi: 3.0.3
info:
title: EGM API
- version: 3.4.0
+ version: 3.5.0
contact:
name: EGM Technical Lead
email: egm-technical@esn.org
@@ -46,6 +46,8 @@ tags:
description: The speakers of the event
- name: Sessions
description: The sessions of the event
+ - name: Contests
+ description: The contests of the event
paths:
/status:
@@ -1032,6 +1034,38 @@ paths:
$ref: '#/components/responses/Session'
400:
$ref: '#/components/responses/BadParameters'
+ patch:
+ summary: Actions on a session
+ tags: [Sessions]
+ security:
+ - AuthFunction: []
+ parameters:
+ - name: sessionId
+ in: path
+ required: true
+ schema:
+ type: string
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ action:
+ type: string
+ enum: [GIVE_FEEDBACK]
+ rating:
+ type: number
+ description: (GIVE_FEEDBACK)
+ comment:
+ type: string
+ description: (GIVE_FEEDBACK)
+ responses:
+ 200:
+ $ref: '#/components/responses/OperationCompleted'
+ 400:
+ $ref: '#/components/responses/BadParameters'
delete:
summary: Delete a session
description: Requires to be content manager
@@ -1060,6 +1094,11 @@ paths:
in: query
schema:
type: string
+ - name: export
+ in: query
+ description: If set, returns an exportable version of the registrations; it requires to be content manager
+ schema:
+ type: boolean
responses:
200:
$ref: '#/components/responses/Registrations'
@@ -1163,6 +1202,118 @@ paths:
$ref: '#/components/responses/OperationCompleted'
400:
$ref: '#/components/responses/BadParameters'
+ /contests:
+ get:
+ summary: Get the contests
+ tags: [Contests]
+ security:
+ - AuthFunction: []
+ responses:
+ 200:
+ $ref: '#/components/responses/Contests'
+ post:
+ summary: Insert a new contest
+ description: Requires to be content manager
+ tags: [Contests]
+ security:
+ - AuthFunction: []
+ requestBody:
+ required: true
+ description: Contest
+ content:
+ application/json:
+ schema:
+ type: object
+ responses:
+ 200:
+ $ref: '#/components/responses/Contest'
+ 400:
+ $ref: '#/components/responses/BadParameters'
+ /contests/{contestId}:
+ get:
+ summary: Get a contest
+ tags: [Contests]
+ security:
+ - AuthFunction: []
+ parameters:
+ - name: contestId
+ in: path
+ required: true
+ schema:
+ type: string
+ responses:
+ 200:
+ $ref: '#/components/responses/Contest'
+ put:
+ summary: Edit a contest
+ description: Requires to be content manager
+ tags: [Contests]
+ security:
+ - AuthFunction: []
+ parameters:
+ - name: contestId
+ in: path
+ required: true
+ schema:
+ type: string
+ requestBody:
+ required: true
+ description: Contest
+ content:
+ application/json:
+ schema:
+ type: object
+ responses:
+ 200:
+ $ref: '#/components/responses/Contest'
+ 400:
+ $ref: '#/components/responses/BadParameters'
+ patch:
+ summary: Actions on a contest
+ tags: [Contests]
+ security:
+ - AuthFunction: []
+ parameters:
+ - name: contestId
+ in: path
+ required: true
+ schema:
+ type: string
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ action:
+ type: string
+ enum: [VOTE, PUBLISH_RESULTS]
+ candidate:
+ type: string
+ description: (VOTE)
+ responses:
+ 200:
+ $ref: '#/components/responses/OperationCompleted'
+ 400:
+ $ref: '#/components/responses/BadParameters'
+ delete:
+ summary: Delete a contest
+ description: Requires to be content manager
+ tags: [Contests]
+ security:
+ - AuthFunction: []
+ parameters:
+ - name: contestId
+ in: path
+ required: true
+ schema:
+ type: string
+ responses:
+ 200:
+ $ref: '#/components/responses/Contest'
+ 400:
+ $ref: '#/components/responses/BadParameters'
components:
schemas:
@@ -1202,6 +1353,9 @@ components:
Registration:
type: object
additionalProperties: {}
+ Contest:
+ type: object
+ additionalProperties: {}
responses:
AppStatus:
@@ -1374,6 +1528,22 @@ components:
type: array
items:
$ref: '#/components/schemas/Registration'
+ Contest:
+ description: Contest
+ content:
+ application/json:
+ schema:
+ type: object
+ items:
+ $ref: '#/components/schemas/Contest'
+ Contests:
+ description: Contest[]
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/Contest'
BadParameters:
description: Bad input parameters
content:
diff --git a/front-end/android/app/build.gradle b/front-end/android/app/build.gradle
index fcab200f..ddcf5b78 100644
--- a/front-end/android/app/build.gradle
+++ b/front-end/android/app/build.gradle
@@ -8,7 +8,7 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
- versionName "3.4.0"
+ versionName "3.5.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
diff --git a/front-end/angular.json b/front-end/angular.json
index 07e3f558..7e2f31d3 100644
--- a/front-end/angular.json
+++ b/front-end/angular.json
@@ -47,7 +47,11 @@
"js-cookie",
"qrcode",
"maplibre-gl",
- "docs-soap"
+ "docs-soap",
+ "date-fns/format/index.js",
+ "date-fns/_lib/getTimezoneOffsetInMilliseconds/index.js",
+ "date-fns/_lib/toInteger/index.js",
+ "date-fns/_lib/cloneObject/index.js"
]
},
"configurations": {
diff --git a/front-end/package-lock.json b/front-end/package-lock.json
index 66186b5f..c95e8dc8 100644
--- a/front-end/package-lock.json
+++ b/front-end/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "egm-app",
- "version": "3.4.0",
+ "version": "3.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "egm-app",
- "version": "3.4.0",
+ "version": "3.5.0",
"dependencies": {
"@angular/animations": "^17.1.2",
"@angular/common": "^17.1.2",
@@ -28,6 +28,7 @@
"@ionic/storage-angular": "^4.0.0",
"@kolkov/angular-editor": "^3.0.0-beta.0",
"@swimlane/ngx-datatable": "^20.1.0",
+ "date-fns-tz": "^2.0.1",
"docs-soap": "^1.2.1",
"idea-toolbox": "^7.0.5",
"ionicons": "^7.2.2",
@@ -2323,7 +2324,6 @@
"version": "7.23.9",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.9.tgz",
"integrity": "sha512-0CX6F+BI2s9dkUqr08KFrAIZgNFj75rdBU/DjCyYLIaV/quFjkk6T+EJ2LkZHyZTbEV4L5p97mNkUsHl2wLFAw==",
- "dev": true,
"dependencies": {
"regenerator-runtime": "^0.14.0"
},
@@ -6883,6 +6883,30 @@
"node": ">=4"
}
},
+ "node_modules/date-fns": {
+ "version": "2.30.0",
+ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz",
+ "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==",
+ "peer": true,
+ "dependencies": {
+ "@babel/runtime": "^7.21.0"
+ },
+ "engines": {
+ "node": ">=0.11"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/date-fns"
+ }
+ },
+ "node_modules/date-fns-tz": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-2.0.1.tgz",
+ "integrity": "sha512-fJCG3Pwx8HUoLhkepdsP7Z5RsucUi+ZBOxyM5d0ZZ6c4SdYustq0VMmOu6Wf7bli+yS/Jwp91TOCqn9jMcVrUA==",
+ "peerDependencies": {
+ "date-fns": "2.x"
+ }
+ },
"node_modules/debounce": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz",
@@ -12543,8 +12567,7 @@
"node_modules/regenerator-runtime": {
"version": "0.14.1",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
- "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==",
- "dev": true
+ "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw=="
},
"node_modules/regenerator-transform": {
"version": "0.15.2",
diff --git a/front-end/package.json b/front-end/package.json
index 08f16988..44c5d577 100644
--- a/front-end/package.json
+++ b/front-end/package.json
@@ -1,6 +1,6 @@
{
"name": "egm-app",
- "version": "3.4.0",
+ "version": "3.5.0",
"author": "ITER IDEA",
"homepage": "https://iter-idea.com/",
"scripts": {
@@ -31,6 +31,7 @@
"@ionic/storage-angular": "^4.0.0",
"@kolkov/angular-editor": "^3.0.0-beta.0",
"@swimlane/ngx-datatable": "^20.1.0",
+ "date-fns-tz": "^2.0.1",
"docs-soap": "^1.2.1",
"idea-toolbox": "^7.0.5",
"ionicons": "^7.2.2",
diff --git a/front-end/src/app/common/datetimeWithTimezone.ts b/front-end/src/app/common/datetimeWithTimezone.ts
new file mode 100644
index 00000000..52331821
--- /dev/null
+++ b/front-end/src/app/common/datetimeWithTimezone.ts
@@ -0,0 +1,94 @@
+import {
+ Component,
+ ElementRef,
+ EventEmitter,
+ Input,
+ OnChanges,
+ OnInit,
+ Output,
+ SimpleChanges,
+ ViewChild
+} from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { IonicModule } from '@ionic/angular';
+import { formatInTimeZone, zonedTimeToUtc } from 'date-fns-tz';
+import { epochISOString } from 'idea-toolbox';
+
+import { AppService } from '@app/app.service';
+
+@Component({
+ standalone: true,
+ imports: [CommonModule, FormsModule, IonicModule],
+ selector: 'app-datetime-timezone',
+ template: `
+
+ {{ label }} @if(obligatory) {}
+
+
+
+ `
+})
+export class DatetimeWithTimezoneStandaloneComponent implements OnInit, OnChanges {
+ /**
+ * The date to manage.
+ */
+ @Input() date: epochISOString;
+ @Output() dateChange = new EventEmitter();
+ /**
+ * The timezone to consider.
+ * Fallback to the default value set in the configurations.
+ */
+ @Input() timezone: string;
+ /**
+ * A label for the item.
+ */
+ @Input() label: string;
+ /**
+ * The color of the item.
+ */
+ @Input() color: string;
+ /**
+ * The lines attribute of the item.
+ */
+ @Input() lines: string;
+ /**
+ * Whether the component is disabled or editable.
+ */
+ @Input() disabled = false;
+ /**
+ * Whether the date is obligatory.
+ */
+ @Input() obligatory = false;
+
+ initialValue: epochISOString;
+
+ @ViewChild('dateTime') dateTime: ElementRef;
+
+ constructor(public app: AppService) {}
+ async ngOnInit(): Promise {
+ this.timezone = this.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone;
+ this.initialValue = this.utcToZonedTimeString(this.date);
+ }
+ ngOnChanges(changes: SimpleChanges): void {
+ // fix the date if the linked timezone changes
+ if (changes.timezone?.currentValue && this.dateTime) {
+ setTimeout((): void => {
+ this.dateChange.emit(this.zonedTimeStringToUTC(this.dateTime.nativeElement.value));
+ }, 100);
+ }
+ }
+
+ utcToZonedTimeString(isoString: epochISOString): string {
+ return formatInTimeZone(isoString, this.timezone, "yyyy-MM-dd'T'HH:mm");
+ }
+ zonedTimeStringToUTC(dateLocale: string): epochISOString {
+ return zonedTimeToUtc(new Date(dateLocale), this.timezone).toISOString();
+ }
+}
diff --git a/front-end/src/app/tabs/contests/contest.page.ts b/front-end/src/app/tabs/contests/contest.page.ts
new file mode 100644
index 00000000..965cf207
--- /dev/null
+++ b/front-end/src/app/tabs/contests/contest.page.ts
@@ -0,0 +1,225 @@
+import { Component, OnInit, inject } from '@angular/core';
+import { ActivatedRoute } from '@angular/router';
+import { FormsModule } from '@angular/forms';
+import { CommonModule } from '@angular/common';
+import { AlertController, IonicModule, ModalController } from '@ionic/angular';
+import {
+ IDEALoadingService,
+ IDEAMessageService,
+ IDEATranslationsModule,
+ IDEATranslationsService
+} from '@idea-ionic/common';
+
+import { HTMLEditorComponent } from '@common/htmlEditor.component';
+import { ManageContestComponent } from './manageContest.component';
+
+import { AppService } from '@app/app.service';
+import { ContestsService } from './contests.service';
+
+import { Contest } from '@models/contest.model';
+
+@Component({
+ standalone: true,
+ imports: [CommonModule, FormsModule, IonicModule, IDEATranslationsModule, HTMLEditorComponent],
+ selector: 'app-contest',
+ template: `
+
+
+
+
+
+
+
+ {{ 'CONTESTS.DETAILS' | translate }}
+ @if(_app.user.permissions.canManageContents) {
+
+
+
+
+
+ }
+
+
+
+ @if(contest) {
+
+
+
+
+ {{ contest.name }}
+
+ @if(contest.isVoteStarted()) { @if(contest.isVoteEnded()) { @if(contest.publishedResults) {
+ {{ 'CONTESTS.RESULTS' | translate }}
+ } @else {
+ {{ 'CONTESTS.VOTE_ENDED' | translate }}
+ } } @else {
+
+ {{ 'CONTESTS.VOTE_NOW_UNTIL' | translate : { deadline: (contest.voteEndsAt | dateLocale : 'short') } }}
+
+ } } @else {
+ {{ 'CONTESTS.VOTE_NOT_OPEN_YET' | translate }}
+ }
+
+
+
+ @if(contest.description) {
+
+ }
+
+
+
+ {{ 'CONTESTS.CANDIDATES' | translate }}
+
+
+
+ @for(candidate of contest.candidates; track candidate.name) {
+
+ @if(canUserVote()) {
+
+ }
+
+ {{ candidate.name }}
+ {{ candidate.country }}
+
+ @if(candidate.url) {
+
+
+
+ } @if(contest.publishedResults) { @if(isCandidateWinnerByIndex($index)) {
+
+ }
+
+ {{ contest.results[$index] ?? 0 }} {{ 'CONTESTS.VOTES' | translate | lowercase }}
+
+ }
+
+ }
+
+ @if(canUserVote()) {
+ {{ 'CONTESTS.VOTE_I' | translate }}
+
+ {{ 'CONTESTS.VOTE' | translate }}
+
+ } @if(userVoted()) {
+
+ {{ 'CONTESTS.YOU_ALREADY_VOTED' | translate }}
+
+ }
+
+
+
+
+ }
+
+ `,
+ styles: [
+ `
+ ion-card {
+ ion-img {
+ height: 300px;
+ object-fit: cover;
+ }
+ ion-card-header {
+ padding-bottom: 0;
+ }
+ }
+ ion-list-header ion-label b {
+ font-size: 1.2em;
+ font-weight: 500;
+ color: var(--ion-color-step-700);
+ }
+ `
+ ]
+})
+export class ContestPage implements OnInit {
+ contest: Contest;
+
+ private _route = inject(ActivatedRoute);
+ private _modal = inject(ModalController);
+ private _alert = inject(AlertController);
+ private _loading = inject(IDEALoadingService);
+ private _message = inject(IDEAMessageService);
+ private _t = inject(IDEATranslationsService);
+ private _contests = inject(ContestsService);
+ _app = inject(AppService);
+
+ voteForCandidate: string;
+
+ async ngOnInit(): Promise {
+ await this.loadData();
+ }
+
+ async loadData(): Promise {
+ try {
+ await this._loading.show();
+ const contestId = this._route.snapshot.paramMap.get('contestId');
+ this.contest = await this._contests.getById(contestId);
+ } catch (err) {
+ this._message.error('COMMON.NOT_FOUND');
+ } finally {
+ this._loading.hide();
+ }
+ }
+
+ async manageContest(contest: Contest): Promise {
+ if (!this._app.user.permissions.canManageContents) return;
+
+ const modal = await this._modal.create({
+ component: ManageContestComponent,
+ componentProps: { contest },
+ backdropDismiss: false
+ });
+ modal.onDidDismiss().then(async (): Promise => {
+ this.contest = await this._contests.getById(contest.contestId);
+ });
+ await modal.present();
+ }
+
+ backToList(): void {
+ this._app.goToInTabs(['contests'], { back: true });
+ }
+
+ isCandidateWinnerByIndex(candidateIndex: number): boolean {
+ return this.contest.candidates.every(
+ (_, competitorIndex): boolean => this.contest.results[competitorIndex] <= this.contest.results[candidateIndex]
+ );
+ }
+ canUserVote(checkCountry?: string): boolean {
+ const voteOpen = this.contest.isVoteStarted() && !this.contest.isVoteEnded();
+ const canVoteCountry = !checkCountry || checkCountry === this._app.user.sectionCountry;
+ const hasConfirmedSpot = this._app.user.spot?.paymentConfirmedAt;
+ const isESNer = !this._app.user.isExternal();
+ return voteOpen && canVoteCountry && !this.userVoted() && hasConfirmedSpot && isESNer;
+ }
+ userVoted(): boolean {
+ return this._app.user.votedInContests.includes(this.contest.contestId);
+ }
+
+ async vote(): Promise {
+ const doVote = async (): Promise => {
+ try {
+ await this._loading.show();
+ await this._contests.vote(this.contest, this.voteForCandidate);
+ this._app.user.votedInContests.push(this.contest.contestId);
+ } catch (err) {
+ this._message.error('COMMON.OPERATION_FAILED');
+ } finally {
+ this._loading.hide();
+ }
+ };
+
+ const header = this._t._('CONTESTS.YOU_ARE_VOTING');
+ const subHeader = this.voteForCandidate;
+ const message = this._t._('CONTESTS.VOTE_I');
+ const buttons = [
+ { text: this._t._('CONTESTS.NOT_NOW'), role: 'cancel' },
+ { text: this._t._('CONTESTS.VOTE'), handler: doVote }
+ ];
+ const alert = await this._alert.create({ header, subHeader, message, buttons });
+ alert.present();
+ }
+}
diff --git a/front-end/src/app/tabs/contests/contests.module.ts b/front-end/src/app/tabs/contests/contests.module.ts
new file mode 100644
index 00000000..6f48f084
--- /dev/null
+++ b/front-end/src/app/tabs/contests/contests.module.ts
@@ -0,0 +1,10 @@
+import { NgModule } from '@angular/core';
+
+import { ContestsRoutingModule } from './contests.routing.module';
+import { ContestsPage } from './contests.page';
+import { ContestPage } from './contest.page';
+
+@NgModule({
+ imports: [ContestsRoutingModule, ContestsPage, ContestPage]
+})
+export class ContestsModule {}
diff --git a/front-end/src/app/tabs/contests/contests.page.ts b/front-end/src/app/tabs/contests/contests.page.ts
new file mode 100644
index 00000000..a8ef07ec
--- /dev/null
+++ b/front-end/src/app/tabs/contests/contests.page.ts
@@ -0,0 +1,94 @@
+import { Component, OnInit, inject } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { IonInfiniteScroll, IonicModule } from '@ionic/angular';
+import { IDEAMessageService, IDEATranslationsModule } from '@idea-ionic/common';
+
+import { AppService } from '@app/app.service';
+import { ContestsService } from './contests.service';
+
+import { Contest } from '@models/contest.model';
+
+@Component({
+ standalone: true,
+ imports: [CommonModule, IonicModule, IDEATranslationsModule],
+ selector: 'app-contests',
+ template: `
+
+ @if(_app.isInMobileMode()) {
+
+ {{ 'CONTESTS.LIST' | translate }}
+
+ }
+
+
+
+
+ @for(contest of contests; track contest.contestId) {
+
+ {{ contest.name }}
+ @if(contest.isVoteStarted()) { @if(contest.isVoteEnded()) { @if(contest.publishedResults) {
+ {{ 'CONTESTS.RESULTS' | translate }}
+ } @else {
+ {{ 'CONTESTS.VOTE_ENDED' | translate }}
+ } } @else {
+ {{ 'CONTESTS.VOTE_NOW' | translate }}
+ } }
+
+ } @empty { @if(contests) {
+
+ {{ 'COMMON.NO_ELEMENT_FOUND' | translate }}
+
+ } @else {
+
+
+
+ } }
+
+
+
+
+
+ `,
+ styles: [
+ `
+ ion-list {
+ padding: 0;
+ max-width: 500px;
+ margin: 0 auto;
+ }
+ `
+ ]
+})
+export class ContestsPage implements OnInit {
+ contests: Contest[];
+
+ private _message = inject(IDEAMessageService);
+ private _contests = inject(ContestsService);
+ _app = inject(AppService);
+
+ async ngOnInit(): Promise {
+ await this.loadData();
+ }
+
+ async loadData(): Promise {
+ try {
+ this.contests = await this._contests.getList({});
+ } catch (error) {
+ this._message.error('COMMON.OPERATION_FAILED');
+ }
+ }
+
+ async filterContests(search = '', scrollToNextPage?: IonInfiniteScroll): Promise {
+ let startPaginationAfterId = null;
+ if (scrollToNextPage && this.contests?.length)
+ startPaginationAfterId = this.contests[this.contests.length - 1].contestId;
+
+ this.contests = await this._contests.getList({ search, withPagination: true, startPaginationAfterId });
+
+ if (scrollToNextPage) setTimeout((): Promise => scrollToNextPage.complete(), 100);
+ }
+
+ selectContest(contest: Contest): void {
+ this._app.goToInTabs(['contests', contest.contestId]);
+ }
+}
diff --git a/front-end/src/app/tabs/contests/contests.routing.module.ts b/front-end/src/app/tabs/contests/contests.routing.module.ts
new file mode 100644
index 00000000..9bac7b26
--- /dev/null
+++ b/front-end/src/app/tabs/contests/contests.routing.module.ts
@@ -0,0 +1,16 @@
+import { NgModule } from '@angular/core';
+import { Routes, RouterModule } from '@angular/router';
+
+import { ContestsPage } from './contests.page';
+import { ContestPage } from './contest.page';
+
+const routes: Routes = [
+ { path: '', component: ContestsPage },
+ { path: ':contestId', component: ContestPage }
+];
+
+@NgModule({
+ imports: [RouterModule.forChild(routes)],
+ exports: [RouterModule]
+})
+export class ContestsRoutingModule {}
diff --git a/front-end/src/app/tabs/contests/contests.service.ts b/front-end/src/app/tabs/contests/contests.service.ts
new file mode 100644
index 00000000..71854ff3
--- /dev/null
+++ b/front-end/src/app/tabs/contests/contests.service.ts
@@ -0,0 +1,102 @@
+import { Injectable } from '@angular/core';
+import { IDEAApiService } from '@idea-ionic/common';
+
+import { Contest } from '@models/contest.model';
+
+@Injectable({ providedIn: 'root' })
+export class ContestsService {
+ private contests: Contest[];
+
+ /**
+ * The number of contests to consider for the pagination, when active.
+ */
+ MAX_PAGE_SIZE = 24;
+
+ constructor(private api: IDEAApiService) {}
+
+ private async loadList(): Promise {
+ const contests: Contest[] = await this.api.getResource('contests');
+ this.contests = contests.map(c => new Contest(c));
+ }
+
+ /**
+ * Get (and optionally filter) the list of contests.
+ * Note: it can be paginated.
+ * Note: it's a slice of the array.
+ * Note: if venue id is passed, it will filter contests for that venue.
+ */
+ async getList(options: {
+ force?: boolean;
+ withPagination?: boolean;
+ startPaginationAfterId?: string;
+ search?: string;
+ }): Promise {
+ if (!this.contests || options.force) await this.loadList();
+ if (!this.contests) return null;
+
+ options.search = options.search ? String(options.search).toLowerCase() : '';
+
+ let filteredList = this.contests.slice();
+
+ if (options.search)
+ filteredList = filteredList.filter(x =>
+ options.search
+ .split(' ')
+ .every(searchTerm =>
+ [x.contestId, x.name, x.description, ...x.candidates.map(x => x.name)]
+ .filter(f => f)
+ .some(f => f.toLowerCase().includes(searchTerm))
+ )
+ );
+
+ if (options.withPagination && filteredList.length > this.MAX_PAGE_SIZE) {
+ let indexOfLastOfPreviousPage = 0;
+ if (options.startPaginationAfterId)
+ indexOfLastOfPreviousPage = filteredList.findIndex(x => x.contestId === options.startPaginationAfterId) || 0;
+ filteredList = filteredList.slice(0, indexOfLastOfPreviousPage + this.MAX_PAGE_SIZE);
+ }
+
+ return filteredList;
+ }
+
+ /**
+ * Get the full details of a contest by its id.
+ */
+ async getById(contestId: string): Promise {
+ return new Contest(await this.api.getResource(['contests', contestId]));
+ }
+
+ /**
+ * Insert a new contest.
+ */
+ async insert(contest: Contest): Promise {
+ return new Contest(await this.api.postResource(['contests'], { body: contest }));
+ }
+ /**
+ * Update an existing contest.
+ */
+ async update(contest: Contest): Promise {
+ return new Contest(await this.api.putResource(['contests', contest.contestId], { body: contest }));
+ }
+ /**
+ * Delete a contest.
+ */
+ async delete(contest: Contest): Promise {
+ await this.api.deleteResource(['contests', contest.contestId]);
+ }
+
+ /**
+ * Vote for a candidate in a contest.
+ */
+ async vote(contest: Contest, candidate: string): Promise {
+ const body = { action: 'VOTE', candidate };
+ await this.api.patchResource(['contests', contest.contestId], { body });
+ }
+ /**
+ * Publish the results of a contest.
+ */
+ async publishResults(contest: Contest): Promise {
+ const body = { action: 'PUBLISH_RESULTS' };
+ await this.api.patchResource(['contests', contest.contestId], { body });
+ }
+}
diff --git a/front-end/src/app/tabs/contests/manageContest.component.ts b/front-end/src/app/tabs/contests/manageContest.component.ts
new file mode 100644
index 00000000..e4e6d0d9
--- /dev/null
+++ b/front-end/src/app/tabs/contests/manageContest.component.ts
@@ -0,0 +1,321 @@
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { Component, Input, OnInit, inject } from '@angular/core';
+import { AlertController, IonicModule, ModalController } from '@ionic/angular';
+import {
+ IDEALoadingService,
+ IDEAMessageService,
+ IDEATranslationsModule,
+ IDEATranslationsService
+} from '@idea-ionic/common';
+
+import { HTMLEditorComponent } from '@common/htmlEditor.component';
+import { DatetimeWithTimezoneStandaloneComponent } from '@common/datetimeWithTimezone';
+
+import { AppService } from '@app/app.service';
+import { MediaService } from '@common/media.service';
+import { ContestsService } from './contests.service';
+
+import { Contest, ContestCandidate } from '@models/contest.model';
+
+@Component({
+ standalone: true,
+ imports: [
+ CommonModule,
+ FormsModule,
+ IonicModule,
+ IDEATranslationsModule,
+ HTMLEditorComponent,
+ DatetimeWithTimezoneStandaloneComponent
+ ],
+ selector: 'app-manage-contest',
+ template: `
+
+
+
+
+
+
+
+ {{ 'CONTESTS.MANAGE_CONTEST' | translate }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ 'CONTESTS.NAME' | translate }}
+
+
+
+
+ {{ 'CONTESTS.IMAGE_URI' | translate }}
+
+
+
+
+
+
+
+
+ {{ 'CONTESTS.OPTIONS' | translate }}
+
+
+
+
+ {{ 'CONTESTS.VISIBLE' | translate }}
+
+ @if(contest.enabled) { @if(!contest.voteEndsAt) {
+
+
+ {{ 'CONTESTS.OPEN_VOTE' | translate }}
+
+ } @else {
+
+
+
+
+
+ } }
+
+
+ {{ 'CONTESTS.CANDIDATES' | translate }}
+ {{ 'CONTESTS.CANDIDATES_I' | translate }}
+
+ @if(!contest.isVoteStarted()){
+
+
+
+ }
+
+ @for(candidate of contest.candidates; track $index) {
+
+
+
+ {{ 'CONTESTS.CANDIDATE_NAME' | translate }}
+
+
+
+
+ {{ 'CONTESTS.CANDIDATE_URL' | translate }}
+
+
+
+
+
+ @for(country of _app.configurations.sectionCountries; track $index) {
+ {{ country }}
+ }
+
+
+ @if(!contest.isVoteStarted()) {
+
+
+
+ }
+
+ } @empty {
+
+ {{ 'COMMON.NO_ELEMENTS' | translate }}
+
+ }
+
+
+ {{ 'CONTESTS.DESCRIPTION' | translate }}
+
+
+
+ @if(contest.isVoteEnded()) {
+
+
+ {{ 'CONTESTS.RESULTS' | translate }}
+
+
+ @for(candidate of contest.candidates; track candidate.name) {
+
+ {{ candidate.name }}
+ @if(isCandidateWinnerByIndex($index)) {
+
+ }
+
+ {{ contest.results[$index] ?? 0 }} {{ 'CONTESTS.VOTES' | translate | lowercase }}
+
+
+ } } @if(contest.contestId) {
+
+ @if(contest.isVoteEnded() && !contest.publishedResults) {
+
+
+ {{ 'CONTESTS.PUBLISH_RESULTS' | translate }}
+
+
+ }
+
+ {{ 'COMMON.DELETE' | translate }}
+
+
+ }
+
+
+ `
+})
+export class ManageContestComponent implements OnInit {
+ /**
+ * The contest to manage.
+ */
+ @Input() contest: Contest;
+
+ errors = new Set();
+
+ timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
+
+ private _modal = inject(ModalController);
+ private _alert = inject(AlertController);
+ private _t = inject(IDEATranslationsService);
+ private _loading = inject(IDEALoadingService);
+ private _message = inject(IDEAMessageService);
+ private _media = inject(MediaService);
+ private _contests = inject(ContestsService);
+ _app = inject(AppService);
+
+ async ngOnInit(): Promise {
+ this.contest = new Contest(this.contest);
+ }
+
+ setVoteDeadline(remove = false): void {
+ if (remove) delete this.contest.voteEndsAt;
+ else {
+ const oneWeekAhead = new Date();
+ oneWeekAhead.setDate(oneWeekAhead.getDate() + 7);
+ this.contest.voteEndsAt = oneWeekAhead.toISOString();
+ }
+ }
+
+ hasFieldAnError(field: string): boolean {
+ return this.errors.has(field);
+ }
+
+ async uploadImage({ target }): Promise {
+ const file = target.files[0];
+ if (!file) return;
+
+ try {
+ await this._loading.show();
+ const imageURI = await this._media.uploadImage(file);
+ await sleepForNumSeconds(3);
+ this.contest.imageURI = imageURI;
+ } catch (error) {
+ this._message.error('COMMON.OPERATION_FAILED');
+ } finally {
+ if (target) target.value = '';
+ this._loading.hide();
+ }
+ }
+
+ async save(): Promise {
+ this.errors = new Set(this.contest.validate());
+ if (this.errors.size) return this._message.error('COMMON.FORM_HAS_ERROR_TO_CHECK');
+
+ try {
+ await this._loading.show();
+ let result: Contest;
+ if (!this.contest.contestId) result = await this._contests.insert(this.contest);
+ else result = await this._contests.update(this.contest);
+ this.contest.load(result);
+ this._message.success('COMMON.OPERATION_COMPLETED');
+ this.close();
+ } catch (err) {
+ this._message.error('COMMON.OPERATION_FAILED');
+ } finally {
+ this._loading.hide();
+ }
+ }
+ close(): void {
+ this._modal.dismiss();
+ }
+
+ async askAndDelete(): Promise {
+ const doDelete = async (): Promise => {
+ try {
+ await this._loading.show();
+ await this._contests.delete(this.contest);
+ this._message.success('COMMON.OPERATION_COMPLETED');
+ this.close();
+ this._app.goToInTabs(['contests']);
+ } catch (error) {
+ this._message.error('COMMON.OPERATION_FAILED');
+ } finally {
+ this._loading.hide();
+ }
+ };
+ const header = this._t._('COMMON.ARE_YOU_SURE');
+ const message = this._t._('COMMON.ACTION_IS_IRREVERSIBLE');
+ const buttons = [
+ { text: this._t._('COMMON.CANCEL'), role: 'cancel' },
+ { text: this._t._('COMMON.DELETE'), role: 'destructive', handler: doDelete }
+ ];
+ const alert = await this._alert.create({ header, message, buttons });
+ alert.present();
+ }
+
+ addCandidate(): void {
+ this.contest.candidates.push(new ContestCandidate());
+ }
+ removeCandidate(candidate: ContestCandidate): void {
+ const candidateIndex = this.contest.candidates.indexOf(candidate);
+ if (candidateIndex !== -1) this.contest.candidates.splice(candidateIndex, 1);
+ }
+
+ isCandidateWinnerByIndex(candidateIndex: number): boolean {
+ return this.contest.candidates.every(
+ (_, competitorIndex): boolean => this.contest.results[competitorIndex] <= this.contest.results[candidateIndex]
+ );
+ }
+
+ async publishResults(): Promise {
+ const doPublish = async (): Promise => {
+ try {
+ await this._loading.show();
+ await this._contests.publishResults(this.contest);
+ this.contest.publishedResults = true;
+ this.close();
+ } catch (err) {
+ this._message.error('COMMON.OPERATION_FAILED');
+ } finally {
+ this._loading.hide();
+ }
+ };
+
+ const header = this._t._('CONTESTS.PUBLISH_RESULTS');
+ const subHeader = this._t._('COMMON.ARE_YOU_SURE');
+ const buttons = [
+ { text: this._t._('COMMON.CANCEL'), role: 'cancel' },
+ { text: this._t._('COMMON.CONFIRM'), handler: doPublish }
+ ];
+ const alert = await this._alert.create({ header, subHeader, buttons });
+ alert.present();
+ }
+}
+
+const sleepForNumSeconds = (numSeconds = 1): Promise =>
+ new Promise(resolve => setTimeout((): void => resolve(null), 1000 * numSeconds));
diff --git a/front-end/src/app/tabs/manage/manage.page.html b/front-end/src/app/tabs/manage/manage.page.html
index 4074d036..217f3ade 100644
--- a/front-end/src/app/tabs/manage/manage.page.html
+++ b/front-end/src/app/tabs/manage/manage.page.html
@@ -40,45 +40,37 @@ {{ 'MANAGE.CONTENTS' | translate }}
{{ 'MANAGE.CONTENTS_I' | translate }}
-
-
+
+
{{ 'MANAGE.VENUES' | translate }}
-
-
+
+
{{ 'MANAGE.ROOMS' | translate }}
-
-
+
+
{{ 'MANAGE.ORGANIZATIONS' | translate }}
-
-
+
+
{{ 'MANAGE.SPEAKERS' | translate }}
-
-
+
+
{{ 'MANAGE.SESSIONS' | translate }}
+
+
+
+
+
+
+ {{ 'MANAGE.CONTESTS' | translate }}
diff --git a/front-end/src/app/tabs/manage/manage.page.ts b/front-end/src/app/tabs/manage/manage.page.ts
index aeeed4ae..d8812fbe 100644
--- a/front-end/src/app/tabs/manage/manage.page.ts
+++ b/front-end/src/app/tabs/manage/manage.page.ts
@@ -1,6 +1,6 @@
import { Component } from '@angular/core';
import { ModalController } from '@ionic/angular';
-import { IDEALoadingService, IDEAMessageService } from '@idea-ionic/common';
+import { IDEALoadingService, IDEAMessageService, IDEATranslationsService } from '@idea-ionic/common';
import { EmailTemplateComponent } from './configurations/emailTemplate/emailTemplate.component';
import { ManageUsefulLinkStandaloneComponent } from '@app/common/usefulLinks/manageUsefulLink.component';
@@ -9,9 +9,11 @@ import { ManageSpeakerComponent } from '../speakers/manageSpeaker.component';
import { ManageVenueComponent } from '../venues/manageVenue.component';
import { ManageRoomComponent } from '../rooms/manageRooms.component';
import { ManageSessionComponent } from '../sessions/manageSession.component';
+import { ManageContestComponent } from '../contests/manageContest.component';
import { AppService } from '@app/app.service';
import { UsefulLinksService } from '@app/common/usefulLinks/usefulLinks.service';
+import { SessionRegistrationsService } from '../sessionRegistrations/sessionRegistrations.service';
import { EmailTemplates, DocumentTemplates } from '@models/configurations.model';
import { UsefulLink } from '@models/usefulLink.model';
@@ -20,6 +22,7 @@ import { Venue } from '@models/venue.model';
import { Speaker } from '@models/speaker.model';
import { Room } from '@models/room.model';
import { Session } from '@models/session.model';
+import { Contest } from '@models/contest.model';
@Component({
selector: 'manage',
@@ -36,7 +39,9 @@ export class ManagePage {
private modalCtrl: ModalController,
private loading: IDEALoadingService,
private message: IDEAMessageService,
+ private t: IDEATranslationsService,
private _usefulLinks: UsefulLinksService,
+ private _sessionRegistrations: SessionRegistrationsService,
public app: AppService
) {}
async ionViewWillEnter(): Promise {
@@ -122,4 +127,24 @@ export class ManagePage {
});
await modal.present();
}
+ async downloadSessionsRegistrations(event?: Event): Promise {
+ if (event) event.stopPropagation();
+ try {
+ await this.loading.show();
+ await this._sessionRegistrations.downloadSpreadsheet(this.t._('SESSIONS.SESSION_REGISTRATIONS'));
+ } catch (error) {
+ this.message.error('COMMON.OPERATION_FAILED');
+ } finally {
+ this.loading.hide();
+ }
+ }
+
+ async addContest(): Promise {
+ const modal = await this.modalCtrl.create({
+ component: ManageContestComponent,
+ componentProps: { contest: new Contest() },
+ backdropDismiss: false
+ });
+ await modal.present();
+ }
}
diff --git a/front-end/src/app/tabs/menu/menu.page.html b/front-end/src/app/tabs/menu/menu.page.html
index 959511f7..12d77454 100644
--- a/front-end/src/app/tabs/menu/menu.page.html
+++ b/front-end/src/app/tabs/menu/menu.page.html
@@ -1,34 +1,36 @@
-
- {{ 'TABS.MENU' | translate }}
-
+ {{ 'TABS.MENU' | translate }}
-
+
{{ 'MENU.PAGES' | translate }}
-
+
{{ 'MENU.HOME' | translate }}
-
+
{{ 'MENU.AGENDA' | translate }}
-
+
{{ 'MENU.VENUES' | translate }}
-
+
{{ 'MENU.ORGANIZATIONS' | translate }}
-
+
{{ 'MENU.SPEAKERS' | translate }}
+
+
+ {{ 'MENU.CONTESTS' | translate }}
+
-
\ No newline at end of file
+
diff --git a/front-end/src/app/tabs/sessionRegistrations/sessionRegistrations.service.ts b/front-end/src/app/tabs/sessionRegistrations/sessionRegistrations.service.ts
index eb29a001..ab97994a 100644
--- a/front-end/src/app/tabs/sessionRegistrations/sessionRegistrations.service.ts
+++ b/front-end/src/app/tabs/sessionRegistrations/sessionRegistrations.service.ts
@@ -1,7 +1,9 @@
import { Injectable } from '@angular/core';
+import { WorkBook, utils, writeFile } from 'xlsx';
import { IDEAApiService } from '@idea-ionic/common';
-import { SessionRegistration } from '@models/sessionRegistration.model';
+import { Session } from '@models/session.model';
+import { SessionRegistration, SessionRegistrationExportable } from '@models/sessionRegistration.model';
@Injectable({ providedIn: 'root' })
export class SessionRegistrationsService {
@@ -35,4 +37,17 @@ export class SessionRegistrationsService {
async delete(registration: SessionRegistration): Promise {
await this.api.deleteResource(['registrations', registration.sessionId]);
}
+
+ /**
+ * Download a spreadsheet containing the sessions registrations selected.
+ */
+ async downloadSpreadsheet(title: string, session?: Session): Promise {
+ const params: any = { export: true };
+ if (session) params.sessionId = session.sessionId;
+ const list: SessionRegistrationExportable[] = await this.api.getResource('registrations', { params });
+
+ const workbook: WorkBook = { SheetNames: [], Sheets: {}, Props: { Title: title } };
+ utils.book_append_sheet(workbook, utils.json_to_sheet(list), '1');
+ writeFile(workbook, title.concat('.xlsx'));
+ }
}
diff --git a/front-end/src/app/tabs/sessions/session.page.html b/front-end/src/app/tabs/sessions/session.page.html
index a53275ef..e7fef14f 100644
--- a/front-end/src/app/tabs/sessions/session.page.html
+++ b/front-end/src/app/tabs/sessions/session.page.html
@@ -1,25 +1,37 @@
-
-
-
-
-
-
-
+ *ngIf="session"
+ [session]="session"
+ [isSessionInFavorites]="isSessionInFavorites(session)"
+ [isUserRegisteredInSession]="isUserRegisteredInSession(session)"
+ [hasUserRatedSession]="hasUserRatedSession(session)"
+ [hasSessionEnded]="hasSessionEnded(session)"
+ (favorite)="toggleFavorite($event, session)"
+ (register)="toggleRegister($event, session)"
+ (giveFeedback)="onGiveFeedback($event, session)"
+ >
+
+
+
+
+
+ @if(session && app.user.permissions.canManageContents) {
+
-
-
-
+
+
+
+
+ }
+
-
\ No newline at end of file
+
diff --git a/front-end/src/app/tabs/sessions/session.page.ts b/front-end/src/app/tabs/sessions/session.page.ts
index a99398ce..a4aee1b4 100644
--- a/front-end/src/app/tabs/sessions/session.page.ts
+++ b/front-end/src/app/tabs/sessions/session.page.ts
@@ -7,6 +7,7 @@ import { IDEALoadingService, IDEAMessageService, IDEATranslationsService } from
import { ManageSessionComponent } from './manageSession.component';
import { SessionsService } from './sessions.service';
+import { SessionRegistrationsService } from '../sessionRegistrations/sessionRegistrations.service';
import { Session } from '@models/session.model';
import { ActivatedRoute } from '@angular/router';
@@ -17,10 +18,10 @@ import { ActivatedRoute } from '@angular/router';
styleUrls: ['./session.page.scss']
})
export class SessionPage implements OnInit {
-
session: Session;
favoriteSessionsIds: string[] = [];
registeredSessionsIds: string[] = [];
+ ratedSessionsIds: string[] = [];
selectedSession: Session;
constructor(
@@ -29,6 +30,7 @@ export class SessionPage implements OnInit {
private loading: IDEALoadingService,
private message: IDEAMessageService,
public _sessions: SessionsService,
+ private _sessionRegistrations: SessionRegistrationsService,
public t: IDEATranslationsService,
public app: AppService
) {}
@@ -45,8 +47,10 @@ export class SessionPage implements OnInit {
// WARNING: do not pass any segment in order to get the favorites on the next api call.
// @todo improvable. Just amke a call to see if a session is or isn't favorited/registerd using a getById
const favoriteSessions = await this._sessions.getList({ force: true });
- this.favoriteSessionsIds = favoriteSessions.map( s => s.sessionId);
- this.registeredSessionsIds = (await this._sessions.loadUserRegisteredSessions()).map(ur => ur.sessionId);
+ this.favoriteSessionsIds = favoriteSessions.map(s => s.sessionId);
+ const userRegisteredSessions = await this._sessions.loadUserRegisteredSessions();
+ this.registeredSessionsIds = userRegisteredSessions.map(ur => ur.sessionId);
+ this.ratedSessionsIds = userRegisteredSessions.filter(ur => ur.hasUserRated).map(ur => ur.sessionId);
} catch (error) {
this.message.error('COMMON.OPERATION_FAILED');
} finally {
@@ -59,7 +63,7 @@ export class SessionPage implements OnInit {
}
async toggleFavorite(ev: any, session: Session): Promise {
- ev?.stopPropagation()
+ ev?.stopPropagation();
try {
await this.loading.show();
if (this.isSessionInFavorites(session)) {
@@ -68,7 +72,7 @@ export class SessionPage implements OnInit {
} else {
await this._sessions.addToFavorites(session.sessionId);
this.favoriteSessionsIds.push(session.sessionId);
- };
+ }
} catch (error) {
this.message.error('COMMON.OPERATION_FAILED');
} finally {
@@ -80,8 +84,16 @@ export class SessionPage implements OnInit {
return this.registeredSessionsIds.includes(session.sessionId);
}
+ hasUserRatedSession(session: Session): boolean {
+ return this.ratedSessionsIds.includes(session.sessionId);
+ }
+
+ hasSessionEnded(session: Session): boolean {
+ return new Date(session.endsAt) < new Date();
+ }
+
async toggleRegister(ev: any, session: Session): Promise {
- ev?.stopPropagation()
+ ev?.stopPropagation();
try {
await this.loading.show();
if (this.isUserRegisteredInSession(session)) {
@@ -92,16 +104,16 @@ export class SessionPage implements OnInit {
await this._sessions.registerInSession(session.sessionId);
this.favoriteSessionsIds.push(session.sessionId);
this.registeredSessionsIds.push(session.sessionId);
- };
+ }
this.session = await this._sessions.getById(session.sessionId);
} catch (error) {
- if (error.message === "User can't sign up for this session!"){
+ if (error.message === "User can't sign up for this session!") {
this.message.error('SESSIONS.CANT_SIGN_UP');
- } else if (error.message === 'Registrations are closed!'){
+ } else if (error.message === 'Registrations are closed!') {
this.message.error('SESSIONS.REGISTRATION_CLOSED');
- } else if (error.message === 'Session is full! Refresh your page.'){
+ } else if (error.message === 'Session is full! Refresh your page.') {
this.message.error('SESSIONS.SESSION_FULL');
- } else if (error.message === 'You have 1 or more sessions during this time period.'){
+ } else if (error.message === 'You have 1 or more sessions during this time period.') {
this.message.error('SESSIONS.OVERLAP');
} else this.message.error('COMMON.OPERATION_FAILED');
} finally {
@@ -109,11 +121,33 @@ export class SessionPage implements OnInit {
}
}
+ async onGiveFeedback(ev: any, session: Session): Promise {
+ try {
+ await this.loading.show();
+ let rating = ev.rating;
+ let comment = ev.comment;
+ if (rating === 0) return this.message.error('SESSIONS.NO_RATING');
+ await this._sessions.giveFeedback(session, rating, comment);
+ this.ratedSessionsIds.push(session.sessionId);
+
+ this.message.success('SESSIONS.FEEDBACK_SENT');
+ } catch (error) {
+ if (error.message === "Can't rate a session without being registered")
+ this.message.error('SESSIONS.NOT_REGISTERED');
+ else if (error.message === 'Already rated this session') this.message.error('SESSIONS.ALREADY_RATED');
+ else if (error.message === "Can't rate a session before it has ended")
+ this.message.error('SESSIONS.STILL_TAKING_PLACE');
+ else if (error.message === 'Invalid rating') this.message.error('SESSIONS.INVALID_RATING');
+ else this.message.error('COMMON.OPERATION_FAILED');
+ } finally {
+ this.loading.hide();
+ }
+ }
async manageSession(): Promise {
if (!this.session) return;
- if (!this.app.user.permissions.canManageContents) return
+ if (!this.app.user.permissions.canManageContents) return;
const modal = await this.modalCtrl.create({
component: ManageSessionComponent,
@@ -130,4 +164,15 @@ export class SessionPage implements OnInit {
});
await modal.present();
}
-}
\ No newline at end of file
+
+ async downloadSessionsRegistrations(): Promise {
+ try {
+ await this.loading.show();
+ await this._sessionRegistrations.downloadSpreadsheet(this.t._('SESSIONS.SESSION_REGISTRATIONS'), this.session);
+ } catch (error) {
+ this.message.error('COMMON.OPERATION_FAILED');
+ } finally {
+ this.loading.hide();
+ }
+ }
+}
diff --git a/front-end/src/app/tabs/sessions/sessionDetail.component.html b/front-end/src/app/tabs/sessions/sessionDetail.component.html
index 1116a882..9977a5e1 100644
--- a/front-end/src/app/tabs/sessions/sessionDetail.component.html
+++ b/front-end/src/app/tabs/sessions/sessionDetail.component.html
@@ -1,85 +1,135 @@
-
-
-
-
- {{ session.code }}
-
-
-
-
- {{ session.name }}
-
-
-
-
-
-
-
- {{ 'SESSIONS.TYPES.' + session.type | translate }}
-
-
-
- {{ app.formatDateShort(session.startsAt) }}
-
-
-
- {{ app.formatTime(session.startsAt) }} - {{ app.formatTime(session.endsAt) }}
-
-
-
- {{ session.durationMinutes }} {{ 'COMMON.MINUTES' | translate }}
-
-
-
- {{ session.room.name }} ({{ session.room.venue.name }})
-
-
-
-
+
+
+
+
+ {{ session.code }}
+
+
+
+
+ {{ session.name }}
+
+
+
+
+
+
+
+ {{ 'SESSIONS.TYPES.' + session.type | translate }}
+
+
+
+
+ {{ 'SESSIONS.GIVE_FEEDBACK' | translate }}
+
+
+
+
+
+
+
+
+
+ {{ 'SESSIONS.FEEDBACK_HINT' | translate }}
+
+
+
+ {{ 'SESSIONS.SUBMIT_FEEDBACK' | translate }}
+
+
+
+
+
+
+ {{ 'SESSIONS.ALREADY_RATED' | translate }}
+
+
+
+
+
+ {{ app.formatDateShort(session.startsAt) }}
+
+
+
+ {{ app.formatTime(session.startsAt) }} - {{ app.formatTime(session.endsAt) }}
+
+
+
+ {{ session.durationMinutes }} {{ 'COMMON.MINUTES' | translate }}
+
+
+
+ {{ session.room.name }} ({{ session.room.venue.name }})
+
+
+
+
+
- {{ speaker.name }}
-
-
-
-
-
-
- {{ session.isFull() ? this.t._('COMMON.FULL') : session.numberOfParticipants + '/' + session.limitOfParticipants }}
-
-
-
-
-
-
-
-
-
-
-
-
+ {{ speaker.name }}
+
-
-
-
-
+
+
+
+
+ {{
+ session.isFull() ? this.t._('COMMON.FULL') : session.numberOfParticipants + '/' + session.limitOfParticipants
+ }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/front-end/src/app/tabs/sessions/sessionDetail.component.ts b/front-end/src/app/tabs/sessions/sessionDetail.component.ts
index 2dbd5c3c..1ff1a854 100644
--- a/front-end/src/app/tabs/sessions/sessionDetail.component.ts
+++ b/front-end/src/app/tabs/sessions/sessionDetail.component.ts
@@ -15,8 +15,13 @@ export class SessionDetailComponent {
@Input() session: Session;
@Input() isSessionInFavorites: boolean;
@Input() isUserRegisteredInSession: boolean;
+ @Input() hasUserRatedSession: boolean;
+ @Input() hasSessionEnded: boolean;
@Output() favorite = new EventEmitter();
@Output() register = new EventEmitter();
+ @Output() giveFeedback = new EventEmitter<{ rating: number; comment?: string }>();
+
+ selectedRating = 0;
constructor(public _sessions: SessionsService, public t: IDEATranslationsService, public app: AppService) {}
}
diff --git a/front-end/src/app/tabs/sessions/sessions.page.html b/front-end/src/app/tabs/sessions/sessions.page.html
index 4578ca69..4211f42e 100644
--- a/front-end/src/app/tabs/sessions/sessions.page.html
+++ b/front-end/src/app/tabs/sessions/sessions.page.html
@@ -1,8 +1,6 @@
-
- {{ 'SESSIONS.LIST' | translate }}
-
+ {{ 'SESSIONS.LIST' | translate }}
@@ -14,9 +12,7 @@
-
- {{ app.formatDateShort(day) }}
-
+ {{ app.formatDateShort(day) }}
@@ -48,24 +44,18 @@
-
-
- {{ session.room.name }} ({{ session.room.venue.name }})
-
+
+ {{ session.room.name }} ({{ session.room.venue.name }})
-
-
- {{ session.getSpeakers() }}
-
+
+ {{ session.getSpeakers() }}
- @if(session.requiresRegistration) {
- {{ session.isFull() ? this.t._('COMMON.FULL') : session.numberOfParticipants + '/' + session.limitOfParticipants }}
- } @else {
- {{ 'COMMON.OPEN' | translate }}
- }
+ @if(session.requiresRegistration) { {{ session.isFull() ? this.t._('COMMON.FULL') :
+ session.numberOfParticipants + '/' + session.limitOfParticipants }} } @else { {{ 'COMMON.OPEN' |
+ translate }} }
-
-
-
-
-
-
-
+
+ @if(selectedSession && app.user.permissions.canManageContents) {
+
+
+
+
+
+
+
+ }
-
\ No newline at end of file
+
diff --git a/front-end/src/app/tabs/sessions/sessions.page.ts b/front-end/src/app/tabs/sessions/sessions.page.ts
index c0917645..a87be453 100644
--- a/front-end/src/app/tabs/sessions/sessions.page.ts
+++ b/front-end/src/app/tabs/sessions/sessions.page.ts
@@ -7,6 +7,7 @@ import { IDEALoadingService, IDEAMessageService, IDEATranslationsService } from
import { ManageSessionComponent } from './manageSession.component';
import { SessionsService } from './sessions.service';
+import { SessionRegistrationsService } from '../sessionRegistrations/sessionRegistrations.service';
import { Session } from '@models/session.model';
@@ -19,20 +20,21 @@ export class SessionsPage {
@ViewChild(IonContent) content: IonContent;
@ViewChild(IonContent) searchbar: IonSearchbar;
-
- days: string[]
+ days: string[];
sessions: Session[];
favoriteSessionsIds: string[] = [];
registeredSessionsIds: string[] = [];
+ ratedSessionsIds: string[] = [];
selectedSession: Session;
- segment = ''
+ segment = '';
constructor(
private modalCtrl: ModalController,
private loading: IDEALoadingService,
private message: IDEAMessageService,
public _sessions: SessionsService,
+ private _sessionRegistrations: SessionRegistrationsService,
public t: IDEATranslationsService,
public app: AppService
) {}
@@ -45,22 +47,24 @@ export class SessionsPage {
try {
await this.loading.show();
// WARNING: do not pass any segment in order to get the favorites on the next api call.
- this.segment = ''
+ this.segment = '';
this.sessions = await this._sessions.getList({ force: true });
- this.favoriteSessionsIds = this.sessions.map( s => s.sessionId);
- this.registeredSessionsIds = (await this._sessions.loadUserRegisteredSessions()).map(ur => ur.sessionId);
- this.days = await this._sessions.getSessionDays()
+ this.favoriteSessionsIds = this.sessions.map(s => s.sessionId);
+ const userRegisteredSessions = await this._sessions.loadUserRegisteredSessions();
+ this.registeredSessionsIds = userRegisteredSessions.map(ur => ur.sessionId);
+ this.ratedSessionsIds = userRegisteredSessions.filter(ur => ur.hasUserRated).map(ur => ur.sessionId);
+ this.days = await this._sessions.getSessionDays();
} catch (error) {
this.message.error('COMMON.OPERATION_FAILED');
} finally {
this.loading.hide();
}
}
- changeSegment (segment: string, search = ''): void {
+ changeSegment(segment: string, search = ''): void {
this.selectedSession = null;
this.segment = segment;
this.filterSessions(search);
- };
+ }
async filterSessions(search = ''): Promise {
this.sessions = await this._sessions.getList({ search, segment: this.segment });
}
@@ -70,7 +74,7 @@ export class SessionsPage {
}
async toggleFavorite(ev: any, session: Session): Promise {
- ev?.stopPropagation()
+ ev?.stopPropagation();
try {
await this.loading.show();
if (this.isSessionInFavorites(session)) {
@@ -80,7 +84,7 @@ export class SessionsPage {
} else {
await this._sessions.addToFavorites(session.sessionId);
this.favoriteSessionsIds.push(session.sessionId);
- };
+ }
} catch (error) {
this.message.error('COMMON.OPERATION_FAILED');
} finally {
@@ -92,8 +96,16 @@ export class SessionsPage {
return this.registeredSessionsIds.includes(session.sessionId);
}
+ hasUserRatedSession(session: Session): boolean {
+ return this.ratedSessionsIds.includes(session.sessionId);
+ }
+
+ hasSessionEnded(session: Session): boolean {
+ return new Date(session.endsAt) < new Date();
+ }
+
async toggleRegister(ev: any, session: Session): Promise {
- ev?.stopPropagation()
+ ev?.stopPropagation();
try {
await this.loading.show();
if (this.isUserRegisteredInSession(session)) {
@@ -105,17 +117,17 @@ export class SessionsPage {
await this._sessions.registerInSession(session.sessionId);
this.favoriteSessionsIds.push(session.sessionId);
this.registeredSessionsIds.push(session.sessionId);
- };
+ }
const updatedSession = await this._sessions.getById(session.sessionId);
session.numberOfParticipants = updatedSession.numberOfParticipants;
} catch (error) {
- if (error.message === "User can't sign up for this session!"){
+ if (error.message === "User can't sign up for this session!") {
this.message.error('SESSIONS.CANT_SIGN_UP');
- } else if (error.message === 'Registrations are closed!'){
+ } else if (error.message === 'Registrations are closed!') {
this.message.error('SESSIONS.REGISTRATION_CLOSED');
- } else if (error.message === 'Session is full! Refresh your page.'){
+ } else if (error.message === 'Session is full! Refresh your page.') {
this.message.error('SESSIONS.SESSION_FULL');
- } else if (error.message === 'You have 1 or more sessions during this time period.'){
+ } else if (error.message === 'You have 1 or more sessions during this time period.') {
this.message.error('SESSIONS.OVERLAP');
} else this.message.error('COMMON.OPERATION_FAILED');
} finally {
@@ -124,17 +136,37 @@ export class SessionsPage {
}
openDetail(ev: any, session: Session): void {
- ev?.stopPropagation()
+ ev?.stopPropagation();
if (this.app.isInMobileMode()) this.app.goToInTabs(['agenda', session.sessionId]);
else this.selectedSession = session;
}
+ async onGiveFeedback(ev: any, session: Session): Promise {
+ try {
+ await this.loading.show();
+ if (ev.rating === 0) return this.message.error('SESSIONS.NO_RATING');
+
+ await this._sessions.giveFeedback(session, ev.rating, ev.comment);
+ this.ratedSessionsIds.push(session.sessionId);
+ this.message.success('SESSIONS.FEEDBACK_SENT');
+ } catch (error) {
+ if (error.message === "Can't rate a session without being registered")
+ this.message.error('SESSIONS.NOT_REGISTERED');
+ else if (error.message === 'Already rated this session') this.message.error('SESSIONS.ALREADY_RATED');
+ else if (error.message === "Can't rate a session before it has ended")
+ this.message.error('SESSIONS.STILL_TAKING_PLACE');
+ else if (error.message === 'Invalid rating') this.message.error('SESSIONS.INVALID_RATING');
+ else this.message.error('COMMON.OPERATION_FAILED');
+ } finally {
+ this.loading.hide();
+ }
+ }
async manageSession(): Promise {
if (!this.selectedSession) return;
- if (!this.app.user.permissions.canManageContents) return
+ if (!this.app.user.permissions.canManageContents) return;
const modal = await this.modalCtrl.create({
component: ManageSessionComponent,
@@ -147,9 +179,23 @@ export class SessionsPage {
} catch (error) {
// deleted
this.selectedSession = null;
- this.sessions = await this._sessions.getList({ force: true })
+ this.sessions = await this._sessions.getList({ force: true });
}
});
await modal.present();
}
-}
\ No newline at end of file
+
+ async downloadSessionsRegistrations(): Promise {
+ try {
+ await this.loading.show();
+ await this._sessionRegistrations.downloadSpreadsheet(
+ this.t._('SESSIONS.SESSION_REGISTRATIONS'),
+ this.selectedSession
+ );
+ } catch (error) {
+ this.message.error('COMMON.OPERATION_FAILED');
+ } finally {
+ this.loading.hide();
+ }
+ }
+}
diff --git a/front-end/src/app/tabs/sessions/sessions.service.ts b/front-end/src/app/tabs/sessions/sessions.service.ts
index c4b2a4f9..04b51461 100644
--- a/front-end/src/app/tabs/sessions/sessions.service.ts
+++ b/front-end/src/app/tabs/sessions/sessions.service.ts
@@ -147,6 +147,17 @@ export class SessionsService {
this.userFavoriteSessions = await this.api.deleteResource(['registrations', sessionId]);
}
+ /**
+ * Rate a session with a number of stars (1-5) and optionally add a feedback comment.
+ */
+ async giveFeedback(session: Session, rating: number, comment?: string): Promise {
+ const body: any = { action: 'GIVE_FEEDBACK', rating };
+ if (comment) {
+ body.comment = comment;
+ }
+ await this.api.patchResource(['sessions', session.sessionId], { body });
+ }
+
getColourBySessionType(session: Session){
switch(session.type) {
case SessionType.DISCUSSION:
diff --git a/front-end/src/app/tabs/tabs.routing.module.ts b/front-end/src/app/tabs/tabs.routing.module.ts
index 91e15d19..186cdede 100644
--- a/front-end/src/app/tabs/tabs.routing.module.ts
+++ b/front-end/src/app/tabs/tabs.routing.module.ts
@@ -42,7 +42,8 @@ const routes: Routes = [
},
{
path: 'organizations',
- loadChildren: (): Promise => import('./organizations/organizations.module').then(m => m.OrganizationsModule),
+ loadChildren: (): Promise =>
+ import('./organizations/organizations.module').then(m => m.OrganizationsModule),
canActivate: [spotGuard]
},
{
@@ -54,6 +55,11 @@ const routes: Routes = [
path: 'agenda',
loadChildren: (): Promise => import('./sessions/sessions.module').then(m => m.SessionsModule),
canActivate: [spotGuard]
+ },
+ {
+ path: 'contests',
+ loadChildren: (): Promise => import('./contests/contests.module').then(m => m.ContestsModule),
+ canActivate: [spotGuard]
}
]
}
diff --git a/front-end/src/assets/i18n/en.json b/front-end/src/assets/i18n/en.json
index e470221f..b3213ea4 100644
--- a/front-end/src/assets/i18n/en.json
+++ b/front-end/src/assets/i18n/en.json
@@ -159,7 +159,8 @@
"AGENDA": "Agenda",
"VENUES": "Venues",
"ORGANIZATIONS": "Organizations",
- "SPEAKERS": "Speakers"
+ "SPEAKERS": "Speakers",
+ "CONTESTS": "Contests"
},
"USER": {
"ESN_ACCOUNTS": "ESN Accounts",
@@ -355,7 +356,9 @@
"OTHER_CONFIGURATIONS": "Other configurations",
"LIST_OF_ESN_COUNTRIES": "List of current ESN countries",
"USEFUL_LINKS": "Useful links",
- "USEFUL_LINKS_I": "Manage the links you want to make available to all users for quick access."
+ "USEFUL_LINKS_I": "Manage the links you want to make available to all users for quick access.",
+ "DOWNLOAD_SESSIONS_REGISTRATIONS": "Download a spreadsheet with all the sessions registrations",
+ "CONTESTS": "Contests"
},
"STRIPE": {
"BEFORE_YOU_PROCEED": "Before you proceed",
@@ -437,6 +440,46 @@
"SPEAKERS": "Speakers",
"NO_SPEAKERS": "No speakers yet...",
"ADD_SPEAKER": "Add speaker",
- "ROOM": "Room"
+ "ROOM": "Room",
+ "DOWNLOAD_REGISTRATIONS": "Download a spreadsheet with all the session registrations",
+ "SESSION_REGISTRATIONS": "Session registrations",
+ "GIVE_FEEDBACK": "Give feedback for this session!",
+ "FEEDBACK_HINT": "Feedback is anonymous and cannot be changed once submitted.",
+ "SUBMIT_FEEDBACK": "Submit",
+ "NO_RATING": "Please provide a rating",
+ "ALREADY_RATED": "Already rated this session",
+ "FEEDBACK_SENT": "Feedback sent",
+ "STILL_TAKING_PLACE": "Session is still taking place",
+ "INVALID_RATING": "Invalid rating",
+ "NOT_REGISTERED": "Not registered"
+ },
+ "CONTESTS": {
+ "MANAGE_CONTEST": "Manage contest",
+ "NAME": "Name",
+ "IMAGE_URI": "Image URI",
+ "DESCRIPTION": "Description",
+ "VISIBLE": "Visible to users",
+ "OPTIONS": "Options",
+ "OPEN_VOTE": "Vote is open",
+ "VOTE_ENDS_AT": "Vote ends at ({{timezone}})",
+ "CANDIDATES": "Candidates",
+ "CANDIDATES_I": "If you set a country for a candidate, users of that country won't be able to vote them.",
+ "RESULTS": "Results",
+ "CANDIDATE_NAME": "Name of the candidate",
+ "CANDIDATE_URL": "URL to a page where to discover the candidate",
+ "CANDIDATE_COUNTRY": "Country of the candidate",
+ "LIST": "Contests list",
+ "DETAILS": "Contest details",
+ "VOTE_ENDED": "Vote ended",
+ "VOTE_NOW": "Vote now",
+ "VOTES": "Votes",
+ "VOTE_NOT_OPEN_YET": "Vote is not open yet",
+ "VOTE_NOW_UNTIL": "Vote now, until: {{deadline}}",
+ "VOTE": "Vote",
+ "VOTE_I": "The vote is anonymous; please not that you can vote only once and you won't be able to change your vote.",
+ "YOU_ALREADY_VOTED": "You have voted.",
+ "YOU_ARE_VOTING": "You are voting",
+ "NOT_NOW": "Not now",
+ "PUBLISH_RESULTS": "Publish results"
}
}
diff --git a/front-end/src/environments/environment.idea.ts b/front-end/src/environments/environment.idea.ts
index b98d46ce..d4ab1f2b 100644
--- a/front-end/src/environments/environment.idea.ts
+++ b/front-end/src/environments/environment.idea.ts
@@ -5,7 +5,7 @@ export const environment = {
idea: {
project: 'egm-app',
app: {
- version: '3.4.0',
+ version: '3.5.0',
bundle: 'com.esn.egmapp',
url: 'https://app.erasmusgeneration.org',
mediaUrl: 'https://media.egm-app.click',
diff --git a/front-end/src/global.scss b/front-end/src/global.scss
index 671fbd16..56597817 100644
--- a/front-end/src/global.scss
+++ b/front-end/src/global.scss
@@ -526,4 +526,3 @@ ion-img.inGallery::part(image) {
.forceMargins p {
margin: 15px 10px !important;
}
-
diff --git a/scripts/README.md b/scripts/README.md
new file mode 100644
index 00000000..8c23d4cc
--- /dev/null
+++ b/scripts/README.md
@@ -0,0 +1,6 @@
+To run the scripts:
+
+- `npm run build` (from `scripts` folder).
+- `node src/trainings` or `node src/deliveries`
+
+_Note: in case of `ExpiredTokenException`, make sure you have signed-in with AWS SSO and ran both: `yawsso -p iter-idea` and `yawsso -p iter-idea-tfm`._
diff --git a/scripts/package-lock.json b/scripts/package-lock.json
new file mode 100644
index 00000000..f762052d
--- /dev/null
+++ b/scripts/package-lock.json
@@ -0,0 +1,454 @@
+{
+ "name": "data-transfer",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "data-transfer",
+ "version": "1.0.0",
+ "dependencies": {
+ "aws-sdk": "^2.1094.0",
+ "commander": "^11.0.0",
+ "idea-toolbox": "^7.0.3"
+ },
+ "devDependencies": {
+ "@tsconfig/node14": "^1.0.0",
+ "@types/node": "^14.14.26",
+ "typescript": "^4.4.3"
+ }
+ },
+ "node_modules/@tsconfig/node14": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
+ "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
+ "dev": true
+ },
+ "node_modules/@types/node": {
+ "version": "14.18.63",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz",
+ "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==",
+ "dev": true
+ },
+ "node_modules/available-typed-arrays": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz",
+ "integrity": "sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/aws-sdk": {
+ "version": "2.1544.0",
+ "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.1544.0.tgz",
+ "integrity": "sha512-R0C9bonDL0IQ/j0tq6Xaq5weFiaiSOj6KGRseHy+78zdbP1tsG2LZSoN3J5RqjjLHA5/fTMwXO1IuW/4eCNLAg==",
+ "dependencies": {
+ "buffer": "4.9.2",
+ "events": "1.1.1",
+ "ieee754": "1.1.13",
+ "jmespath": "0.16.0",
+ "querystring": "0.2.0",
+ "sax": "1.2.1",
+ "url": "0.10.3",
+ "util": "^0.12.4",
+ "uuid": "8.0.0",
+ "xml2js": "0.6.2"
+ },
+ "engines": {
+ "node": ">= 10.0.0"
+ }
+ },
+ "node_modules/base64-js": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ]
+ },
+ "node_modules/buffer": {
+ "version": "4.9.2",
+ "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.2.tgz",
+ "integrity": "sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg==",
+ "dependencies": {
+ "base64-js": "^1.0.2",
+ "ieee754": "^1.1.4",
+ "isarray": "^1.0.0"
+ }
+ },
+ "node_modules/call-bind": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.5.tgz",
+ "integrity": "sha512-C3nQxfFZxFRVoJoGKKI8y3MOEo129NQ+FgQ08iye+Mk4zNZZGdjfs06bVTr+DBSlA66Q2VEcMki/cUCP4SercQ==",
+ "dependencies": {
+ "function-bind": "^1.1.2",
+ "get-intrinsic": "^1.2.1",
+ "set-function-length": "^1.1.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/commander": {
+ "version": "11.1.0",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz",
+ "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==",
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/define-data-property": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.1.tgz",
+ "integrity": "sha512-E7uGkTzkk1d0ByLeSc6ZsFS79Axg+m1P/VsgYsxHgiuc3tFSj+MjMIwe90FC4lOAZzNBdY7kkO2P2wKdsQ1vgQ==",
+ "dependencies": {
+ "get-intrinsic": "^1.2.1",
+ "gopd": "^1.0.1",
+ "has-property-descriptors": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/events": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/events/-/events-1.1.1.tgz",
+ "integrity": "sha512-kEcvvCBByWXGnZy6JUlgAp2gBIUjfCAV6P6TgT1/aaQKcmuAEC4OZTV1I4EWQLz2gxZw76atuVyvHhTxvi0Flw==",
+ "engines": {
+ "node": ">=0.4.x"
+ }
+ },
+ "node_modules/for-each": {
+ "version": "0.3.3",
+ "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz",
+ "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==",
+ "dependencies": {
+ "is-callable": "^1.1.3"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.2.tgz",
+ "integrity": "sha512-0gSo4ml/0j98Y3lngkFEot/zhiCeWsbYIlZ+uZOVgzLyLaUw7wxUL+nCTP0XJvJg1AXulJRI3UJi8GsbDuxdGA==",
+ "dependencies": {
+ "function-bind": "^1.1.2",
+ "has-proto": "^1.0.1",
+ "has-symbols": "^1.0.3",
+ "hasown": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz",
+ "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==",
+ "dependencies": {
+ "get-intrinsic": "^1.1.3"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-property-descriptors": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.1.tgz",
+ "integrity": "sha512-VsX8eaIewvas0xnvinAe9bw4WfIeODpGYikiWYLH+dma0Jw6KHYqWiWfhQlgOVK8D6PvjubK5Uc4P0iIhIcNVg==",
+ "dependencies": {
+ "get-intrinsic": "^1.2.2"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz",
+ "integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz",
+ "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-tostringtag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz",
+ "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==",
+ "dependencies": {
+ "has-symbols": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz",
+ "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/idea-toolbox": {
+ "version": "7.0.3",
+ "resolved": "https://registry.npmjs.org/idea-toolbox/-/idea-toolbox-7.0.3.tgz",
+ "integrity": "sha512-yMEdhabfes68TXVE0V04oiWZMhYMspmKhOo4UkaQFG5K2GaXGXGhiMXx0iYp4zJ7Vb0RYBA04eYJeAvi1TSmrw==",
+ "dependencies": {
+ "marked": "^11.1.1",
+ "validator": "^13.11.0"
+ }
+ },
+ "node_modules/ieee754": {
+ "version": "1.1.13",
+ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz",
+ "integrity": "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg=="
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
+ },
+ "node_modules/is-arguments": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz",
+ "integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==",
+ "dependencies": {
+ "call-bind": "^1.0.2",
+ "has-tostringtag": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-callable": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
+ "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-generator-function": {
+ "version": "1.0.10",
+ "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz",
+ "integrity": "sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==",
+ "dependencies": {
+ "has-tostringtag": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-typed-array": {
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.12.tgz",
+ "integrity": "sha512-Z14TF2JNG8Lss5/HMqt0//T9JeHXttXy5pH/DBU4vi98ozO2btxzq9MwYDZYnKwU8nRsz/+GVFVRDq3DkVuSPg==",
+ "dependencies": {
+ "which-typed-array": "^1.1.11"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="
+ },
+ "node_modules/jmespath": {
+ "version": "0.16.0",
+ "resolved": "https://registry.npmjs.org/jmespath/-/jmespath-0.16.0.tgz",
+ "integrity": "sha512-9FzQjJ7MATs1tSpnco1K6ayiYE3figslrXA72G2HQ/n76RzvYlofyi5QM+iX4YRs/pu3yzxlVQSST23+dMDknw==",
+ "engines": {
+ "node": ">= 0.6.0"
+ }
+ },
+ "node_modules/marked": {
+ "version": "11.1.1",
+ "resolved": "https://registry.npmjs.org/marked/-/marked-11.1.1.tgz",
+ "integrity": "sha512-EgxRjgK9axsQuUa/oKMx5DEY8oXpKJfk61rT5iY3aRlgU6QJtUcxU5OAymdhCvWvhYcd9FKmO5eQoX8m9VGJXg==",
+ "bin": {
+ "marked": "bin/marked.js"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/punycode": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz",
+ "integrity": "sha512-RofWgt/7fL5wP1Y7fxE7/EmTLzQVnB0ycyibJ0OOHIlJqTNzglYFxVwETOcIoJqJmpDXJ9xImDv+Fq34F/d4Dw=="
+ },
+ "node_modules/querystring": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz",
+ "integrity": "sha512-X/xY82scca2tau62i9mDyU9K+I+djTMUsvwf7xnUX5GLvVzgJybOJf4Y6o9Zx3oJK/LSXg5tTZBjwzqVPaPO2g==",
+ "deprecated": "The querystring API is considered Legacy. new code should use the URLSearchParams API instead.",
+ "engines": {
+ "node": ">=0.4.x"
+ }
+ },
+ "node_modules/sax": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.1.tgz",
+ "integrity": "sha512-8I2a3LovHTOpm7NV5yOyO8IHqgVsfK4+UuySrXU8YXkSRX7k6hCV9b3HrkKCr3nMpgj+0bmocaJJWpvp1oc7ZA=="
+ },
+ "node_modules/set-function-length": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.0.tgz",
+ "integrity": "sha512-4DBHDoyHlM1IRPGYcoxexgh67y4ueR53FKV1yyxwFMY7aCqcN/38M1+SwZ/qJQ8iLv7+ck385ot4CcisOAPT9w==",
+ "dependencies": {
+ "define-data-property": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-intrinsic": "^1.2.2",
+ "gopd": "^1.0.1",
+ "has-property-descriptors": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "4.9.5",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
+ "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
+ "dev": true,
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=4.2.0"
+ }
+ },
+ "node_modules/url": {
+ "version": "0.10.3",
+ "resolved": "https://registry.npmjs.org/url/-/url-0.10.3.tgz",
+ "integrity": "sha512-hzSUW2q06EqL1gKM/a+obYHLIO6ct2hwPuviqTTOcfFVc61UbfJ2Q32+uGL/HCPxKqrdGB5QUwIe7UqlDgwsOQ==",
+ "dependencies": {
+ "punycode": "1.3.2",
+ "querystring": "0.2.0"
+ }
+ },
+ "node_modules/util": {
+ "version": "0.12.5",
+ "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz",
+ "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==",
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "is-arguments": "^1.0.4",
+ "is-generator-function": "^1.0.7",
+ "is-typed-array": "^1.1.3",
+ "which-typed-array": "^1.1.2"
+ }
+ },
+ "node_modules/uuid": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.0.0.tgz",
+ "integrity": "sha512-jOXGuXZAWdsTH7eZLtyXMqUb9EcWMGZNbL9YcGBJl4MH4nrxHmZJhEHvyLFrkxo+28uLb/NYRcStH48fnD0Vzw==",
+ "bin": {
+ "uuid": "dist/bin/uuid"
+ }
+ },
+ "node_modules/validator": {
+ "version": "13.11.0",
+ "resolved": "https://registry.npmjs.org/validator/-/validator-13.11.0.tgz",
+ "integrity": "sha512-Ii+sehpSfZy+At5nPdnyMhx78fEoPDkR2XW/zimHEL3MyGJQOCQ7WeP20jPYRz7ZCpcKLB21NxuXHF3bxjStBQ==",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/which-typed-array": {
+ "version": "1.1.13",
+ "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.13.tgz",
+ "integrity": "sha512-P5Nra0qjSncduVPEAr7xhoF5guty49ArDTwzJ/yNuPIbZppyRxFQsRCWrocxIY+CnMVG+qfbU2FmDKyvSGClow==",
+ "dependencies": {
+ "available-typed-arrays": "^1.0.5",
+ "call-bind": "^1.0.4",
+ "for-each": "^0.3.3",
+ "gopd": "^1.0.1",
+ "has-tostringtag": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/xml2js": {
+ "version": "0.6.2",
+ "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz",
+ "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==",
+ "dependencies": {
+ "sax": ">=0.6.0",
+ "xmlbuilder": "~11.0.0"
+ },
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/xmlbuilder": {
+ "version": "11.0.1",
+ "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
+ "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
+ "engines": {
+ "node": ">=4.0"
+ }
+ }
+ }
+}
diff --git a/scripts/package.json b/scripts/package.json
new file mode 100644
index 00000000..f6597797
--- /dev/null
+++ b/scripts/package.json
@@ -0,0 +1,18 @@
+{
+ "name": "data-transfer",
+ "version": "1.0.0",
+ "private": true,
+ "scripts": {
+ "build": "tsc --build"
+ },
+ "dependencies": {
+ "aws-sdk": "^2.1094.0",
+ "commander": "^11.0.0",
+ "idea-toolbox": "^7.0.3"
+ },
+ "devDependencies": {
+ "@tsconfig/node14": "^1.0.0",
+ "@types/node": "^14.14.26",
+ "typescript": "^4.4.3"
+ }
+}
diff --git a/scripts/src/sessionFeedback.ts b/scripts/src/sessionFeedback.ts
new file mode 100644
index 00000000..52f8697d
--- /dev/null
+++ b/scripts/src/sessionFeedback.ts
@@ -0,0 +1,66 @@
+import { DynamoDB } from 'aws-sdk';
+import { InvalidArgumentError, program } from 'commander';
+
+import { initWithSSO, putItemsHelper, scanInfinite } from './utils/ddb.utils';
+
+//
+// PARAMS
+//
+
+program
+ .name('EGM: Adds the feedback results and feedback comments array to the session model to avoid errors.')
+ .option('-e, --env [environment]', 'The target environment', 'prod')
+ .option('-w, --write', 'Whether to write data or just to simulate the execution', false)
+ .showHelpAfterError('\tadd --help for additional information')
+ .parse();
+const options = program.opts();
+
+const AWS_PROFILE = 'egm';
+const DDB_TABLE_REGION = 'eu-central-1';
+const DDB_TABLE_BASE = `egm-${options.env}-api_`;
+
+//
+// MAIN
+//
+
+main();
+
+async function main(): Promise {
+ try {
+ if (!options.env) throw new InvalidArgumentError('Missing environment');
+ } catch (error) {
+ program.error((error as any).message);
+ }
+
+ try {
+ const { ddb } = await initWithSSO(AWS_PROFILE, DDB_TABLE_REGION);
+
+ const sessions: any[] = await getSessions(ddb);
+ sessions.forEach(s => {
+ s.feedbackComments = [];
+ s.feedbackResults = [0, 0, 0, 0, 0];
+ });
+ await putItemsHelper(ddb, DDB_TABLE_BASE.concat('sessions'), sessions, options.write);
+
+ const registrations: any[] = await getRegistrations(ddb);
+ registrations.forEach(r => {
+ r.hasUserRated = false;
+ });
+ await putItemsHelper(ddb, DDB_TABLE_BASE.concat('registrations'), registrations, options.write);
+
+ console.log('[DONE]');
+ } catch (err) {
+ console.error('[ERROR] Operation failed', err);
+ }
+
+ async function getSessions(ddb: DynamoDB.DocumentClient): Promise {
+ const sessions = await scanInfinite(ddb, { TableName: DDB_TABLE_BASE.concat('sessions') });
+ console.log(`Read ${sessions.length} sessions`);
+ return sessions;
+ }
+ async function getRegistrations(ddb: DynamoDB.DocumentClient): Promise {
+ const registrations = await scanInfinite(ddb, { TableName: DDB_TABLE_BASE.concat('registrations') });
+ console.log(`Read ${registrations.length} registrations`);
+ return registrations;
+ }
+}
diff --git a/scripts/src/utils/ddb.utils.ts b/scripts/src/utils/ddb.utils.ts
new file mode 100644
index 00000000..cde72512
--- /dev/null
+++ b/scripts/src/utils/ddb.utils.ts
@@ -0,0 +1,54 @@
+import { DynamoDB, SharedIniFileCredentials } from 'aws-sdk';
+
+export type DDB = DynamoDB.DocumentClient;
+
+export async function initWithSSO(profile: string, region?: string): Promise<{ ddb: DynamoDB.DocumentClient }> {
+ const credentials = new SharedIniFileCredentials({ profile });
+ return { ddb: new DynamoDB.DocumentClient({ region, credentials }) };
+}
+
+export async function scanInfinite(
+ ddb: AWS.DynamoDB.DocumentClient,
+ params: AWS.DynamoDB.DocumentClient.ScanInput,
+ items: AWS.DynamoDB.DocumentClient.AttributeMap[] = []
+): Promise {
+ const result = await ddb.scan(params).promise();
+
+ items = items.concat(result.Items);
+
+ if (result.LastEvaluatedKey) {
+ params.ExclusiveStartKey = result.LastEvaluatedKey;
+ return await scanInfinite(ddb, params, items);
+ } else return items;
+}
+
+export function chunkArray(array: any[], chunkSize: number = 100): any[][] {
+ return array.reduce((resultArray, item, index): any[][] => {
+ const chunkIndex = Math.floor(index / chunkSize);
+ if (!resultArray[chunkIndex]) resultArray[chunkIndex] = [];
+ resultArray[chunkIndex].push(item);
+ return resultArray;
+ }, []);
+}
+
+export async function putItemsHelper(ddb: DDB, tableName: string, items: any[], write = false): Promise {
+ const writeElement = async (ddb: DDB, element: any): Promise => {
+ try {
+ await ddb.put({ TableName: tableName, Item: element }).promise();
+ } catch (error) {
+ console.log(`Put failed (${tableName})`, element);
+ throw error;
+ }
+ };
+
+ if (!write) console.log(`${tableName} preview:`, items.slice(0, 5));
+ else {
+ console.log(`Writing ${tableName}`);
+ const chunkSize = 100;
+ const chunks = chunkArray(items, chunkSize);
+ for (let i = 0; i < chunks.length; i++) {
+ console.log('\tProgress:', i * chunkSize);
+ await Promise.allSettled(chunks[i].map(x => writeElement(ddb, x)));
+ }
+ }
+}
diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json
new file mode 100644
index 00000000..767c3596
--- /dev/null
+++ b/scripts/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "./node_modules/@tsconfig/node14/tsconfig.json",
+ "compilerOptions": {
+ "strictPropertyInitialization": false,
+ "strictNullChecks": false
+ },
+ "include": ["src/**/*.ts"]
+}