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"] +}