From 0bd8bf1e864cc47532f859fc84a25b26cdf97587 Mon Sep 17 00:00:00 2001 From: Matteo Carbone Date: Sat, 23 Mar 2024 13:45:04 +0000 Subject: [PATCH 1/9] refactor: registrations and sessions RCs #128 --- back-end/src/handlers/registrations.ts | 158 +++++++++++-------------- back-end/src/handlers/sessions.ts | 37 ++---- 2 files changed, 83 insertions(+), 112 deletions(-) diff --git a/back-end/src/handlers/registrations.ts b/back-end/src/handlers/registrations.ts index 83520f9..bbae0d6 100644 --- a/back-end/src/handlers/registrations.ts +++ b/back-end/src/handlers/registrations.ts @@ -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,14 +56,11 @@ 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'); @@ -70,24 +68,12 @@ class SessionRegistrations extends ResourceController { } 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'); - } - } else { - return await this.getUsersRegistrations(this.principalId); - } + if (this.queryParams.sessionId) return this.getSessionRegistrations(this.queryParams.sessionId); + else return await this.getUsersRegistrations(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 +91,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 = new Session(await ddb.get({ TableName: DDB_TABLES.sessions, Key: { 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.getUsersRegistrations(userId); if (!userRegistrations.length) return true; - const sessions: Session[] = ( + const sessions = ( await ddb.batchGet( DDB_TABLES.sessions, userRegistrations.map(ur => ({ sessionId: ur.sessionId })) @@ -215,7 +187,7 @@ class SessionRegistrations extends ResourceController { private async getUsersRegistrations(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 +198,16 @@ class SessionRegistrations extends ResourceController { throw new HandledError('Could not load registrations for this user'); } } + private async getSessionRegistrations(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'); + } + } } diff --git a/back-end/src/handlers/sessions.ts b/back-end/src/handlers/sessions.ts index bd84097..1002aa7 100644 --- a/back-end/src/handlers/sessions.ts +++ b/back-end/src/handlers/sessions.ts @@ -14,23 +14,21 @@ import { User } from '../models/user.model'; /// 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 }; - 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; @@ -73,12 +71,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(); @@ -98,11 +95,7 @@ class Sessions extends ResourceController { protected async deleteResource(): Promise { if (!this.user.permissions.canManageContents) throw new HandledError('Unauthorized'); - try { - await ddb.delete({ TableName: DDB_TABLES.sessions, Key: { sessionId: this.resourceId } }); - } catch (err) { - throw new HandledError('Delete failed'); - } + await ddb.delete({ TableName: DDB_TABLES.sessions, Key: { sessionId: this.resourceId } }); } protected async postResources(): Promise { @@ -115,20 +108,14 @@ 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 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) + ); - return sortedSessions; - } catch (err) { - throw new HandledError('Operation failed'); - } + return filtertedSessions.sort((a, b): number => a.startsAt.localeCompare(b.startsAt)); } } From 803dcaebf72549da9356aa5ae8603b657f199115 Mon Sep 17 00:00:00 2001 From: Matteo Carbone Date: Sat, 23 Mar 2024 13:48:13 +0000 Subject: [PATCH 2/9] refactor: prettier on save #128 --- .../src/app/tabs/manage/manage.page.html | 30 ++---------- .../src/app/tabs/sessions/session.page.html | 40 ++++++++-------- .../src/app/tabs/sessions/session.page.ts | 24 +++++----- .../src/app/tabs/sessions/sessions.page.html | 46 ++++++++----------- .../src/app/tabs/sessions/sessions.page.ts | 40 ++++++++-------- 5 files changed, 73 insertions(+), 107 deletions(-) diff --git a/front-end/src/app/tabs/manage/manage.page.html b/front-end/src/app/tabs/manage/manage.page.html index 4074d03..0796d5a 100644 --- a/front-end/src/app/tabs/manage/manage.page.html +++ b/front-end/src/app/tabs/manage/manage.page.html @@ -40,43 +40,23 @@

{{ 'MANAGE.CONTENTS' | translate }}

{{ 'MANAGE.CONTENTS_I' | translate }}

- + {{ 'MANAGE.VENUES' | translate }} - + {{ 'MANAGE.ROOMS' | translate }} - + {{ 'MANAGE.ORGANIZATIONS' | translate }} - + {{ 'MANAGE.SPEAKERS' | translate }} - + {{ 'MANAGE.SESSIONS' | translate }} diff --git a/front-end/src/app/tabs/sessions/session.page.html b/front-end/src/app/tabs/sessions/session.page.html index a53275e..dd1dbab 100644 --- a/front-end/src/app/tabs/sessions/session.page.html +++ b/front-end/src/app/tabs/sessions/session.page.html @@ -1,25 +1,25 @@
- - - - - - - - - + *ngIf="session" + [session]="session" + [isSessionInFavorites]="isSessionInFavorites(session)" + [isUserRegisteredInSession]="isUserRegisteredInSession(session)" + (favorite)="toggleFavorite($event, session)" + (register)="toggleRegister($event, session)" + > + + + - - - + + + + + + + + +
-
\ 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 a99398c..b7fc961 100644 --- a/front-end/src/app/tabs/sessions/session.page.ts +++ b/front-end/src/app/tabs/sessions/session.page.ts @@ -17,7 +17,6 @@ import { ActivatedRoute } from '@angular/router'; styleUrls: ['./session.page.scss'] }) export class SessionPage implements OnInit { - session: Session; favoriteSessionsIds: string[] = []; registeredSessionsIds: string[] = []; @@ -45,7 +44,7 @@ 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.favoriteSessionsIds = favoriteSessions.map(s => s.sessionId); this.registeredSessionsIds = (await this._sessions.loadUserRegisteredSessions()).map(ur => ur.sessionId); } catch (error) { this.message.error('COMMON.OPERATION_FAILED'); @@ -59,7 +58,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 +67,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 { @@ -81,7 +80,7 @@ export class SessionPage implements OnInit { } async toggleRegister(ev: any, session: Session): Promise { - ev?.stopPropagation() + ev?.stopPropagation(); try { await this.loading.show(); if (this.isUserRegisteredInSession(session)) { @@ -92,16 +91,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 +108,10 @@ export class SessionPage implements OnInit { } } - 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 +128,4 @@ export class SessionPage implements OnInit { }); await modal.present(); } -} \ No newline at end of file +} diff --git a/front-end/src/app/tabs/sessions/sessions.page.html b/front-end/src/app/tabs/sessions/sessions.page.html index 4578ca6..3f08778 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 }} } - + @@ -112,4 +102,4 @@ -
\ 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 c091764..7307b53 100644 --- a/front-end/src/app/tabs/sessions/sessions.page.ts +++ b/front-end/src/app/tabs/sessions/sessions.page.ts @@ -19,14 +19,13 @@ export class SessionsPage { @ViewChild(IonContent) content: IonContent; @ViewChild(IonContent) searchbar: IonSearchbar; - - days: string[] + days: string[]; sessions: Session[]; favoriteSessionsIds: string[] = []; registeredSessionsIds: string[] = []; selectedSession: Session; - segment = '' + segment = ''; constructor( private modalCtrl: ModalController, @@ -45,22 +44,22 @@ 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.favoriteSessionsIds = this.sessions.map(s => s.sessionId); this.registeredSessionsIds = (await this._sessions.loadUserRegisteredSessions()).map(ur => ur.sessionId); - this.days = await this._sessions.getSessionDays() + 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 +69,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 +79,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 { @@ -93,7 +92,7 @@ export class SessionsPage { } async toggleRegister(ev: any, session: Session): Promise { - ev?.stopPropagation() + ev?.stopPropagation(); try { await this.loading.show(); if (this.isUserRegisteredInSession(session)) { @@ -105,17 +104,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 +123,16 @@ 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 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 +145,9 @@ 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 +} From c93c1cb384a02bc7a16355cd91a24ed1ee86eb12 Mon Sep 17 00:00:00 2001 From: Matteo Carbone Date: Sat, 23 Mar 2024 14:15:01 +0000 Subject: [PATCH 3/9] feat(sessionsRegistrations): download spreadsheet #128 --- back-end/src/handlers/registrations.ts | 53 ++++++++++++++++--- .../src/models/sessionRegistration.model.ts | 36 +++++++++++++ back-end/swagger.yaml | 5 ++ .../src/app/tabs/manage/manage.page.html | 8 +++ front-end/src/app/tabs/manage/manage.page.ts | 16 +++++- .../sessionRegistrations.service.ts | 17 +++++- .../src/app/tabs/sessions/session.page.html | 21 +++++--- .../src/app/tabs/sessions/session.page.ts | 13 +++++ .../src/app/tabs/sessions/sessions.page.html | 21 +++++--- .../src/app/tabs/sessions/sessions.page.ts | 16 ++++++ front-end/src/assets/i18n/en.json | 7 ++- 11 files changed, 189 insertions(+), 24 deletions(-) diff --git a/back-end/src/handlers/registrations.ts b/back-end/src/handlers/registrations.ts index bbae0d6..f9e197f 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'; @@ -67,9 +67,16 @@ class SessionRegistrationsRC extends ResourceController { } } - protected async getResources(): Promise { - if (this.queryParams.sessionId) return this.getSessionRegistrations(this.queryParams.sessionId); - else return await this.getUsersRegistrations(this.principalId); + protected async getResources(): Promise { + if (this.queryParams.sessionId) { + if (this.queryParams.export && this.user.permissions.canManageContents) + return await this.getExportableSessionRegistrations(this.queryParams.sessionId); + else return this.getRegistrationsOfSessionById(this.queryParams.sessionId); + } else { + if (this.queryParams.export && this.user.permissions.canManageContents) + return await this.getExportableSessionRegistrations(); + else return await this.getRegistrationsOfUserById(this.principalId); + } } protected async postResource(): Promise { @@ -118,7 +125,7 @@ class SessionRegistrationsRC extends ResourceController { private async putSafeResource(): Promise { const { sessionId, userId } = this.registration; - const 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!"); @@ -151,7 +158,7 @@ class SessionRegistrationsRC extends ResourceController { 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 = await this.getUsersRegistrations(userId); + const userRegistrations = await this.getRegistrationsOfUserById(userId); if (!userRegistrations.length) return true; const sessions = ( @@ -185,7 +192,7 @@ class SessionRegistrationsRC extends ResourceController { return true; } - private async getUsersRegistrations(userId: string): Promise { + private async getRegistrationsOfUserById(userId: string): Promise { try { const registrationsOfUser: SessionRegistration[] = await ddb.query({ TableName: DDB_TABLES.registrations, @@ -198,7 +205,7 @@ class SessionRegistrationsRC extends ResourceController { throw new HandledError('Could not load registrations for this user'); } } - private async getSessionRegistrations(sessionId: string): Promise { + private async getRegistrationsOfSessionById(sessionId: string): Promise { try { const registrationsOfSession: SessionRegistration[] = await ddb.query({ TableName: DDB_TABLES.registrations, @@ -210,4 +217,34 @@ class SessionRegistrationsRC extends ResourceController { 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/models/sessionRegistration.model.ts b/back-end/src/models/sessionRegistration.model.ts index eb1a677..666a115 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. @@ -30,4 +32,38 @@ export class SessionRegistration extends Resource { this.name = this.clean(x.name, String); if (x.sectionCountry) this.sectionCountry = this.clean(x.sectionCountry, String); } + + /** + * 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/swagger.yaml b/back-end/swagger.yaml index 4aa3320..6b35cae 100644 --- a/back-end/swagger.yaml +++ b/back-end/swagger.yaml @@ -1060,6 +1060,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' diff --git a/front-end/src/app/tabs/manage/manage.page.html b/front-end/src/app/tabs/manage/manage.page.html index 0796d5a..86ab1cf 100644 --- a/front-end/src/app/tabs/manage/manage.page.html +++ b/front-end/src/app/tabs/manage/manage.page.html @@ -59,6 +59,14 @@

{{ 'MANAGE.CONTENTS' | translate }}

{{ 'MANAGE.SESSIONS' | translate }} + + + diff --git a/front-end/src/app/tabs/manage/manage.page.ts b/front-end/src/app/tabs/manage/manage.page.ts index aeeed4a..19b3943 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'; @@ -12,6 +12,7 @@ import { ManageSessionComponent } from '../sessions/manageSession.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'; @@ -36,7 +37,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 +125,15 @@ 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(); + } + } } diff --git a/front-end/src/app/tabs/sessionRegistrations/sessionRegistrations.service.ts b/front-end/src/app/tabs/sessionRegistrations/sessionRegistrations.service.ts index eb29a00..ab97994 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 dd1dbab..48fcc9e 100644 --- a/front-end/src/app/tabs/sessions/session.page.html +++ b/front-end/src/app/tabs/sessions/session.page.html @@ -13,13 +13,22 @@ - - - - - - + @if(session && app.user.permissions.canManageContents) { + + + + + + + + } diff --git a/front-end/src/app/tabs/sessions/session.page.ts b/front-end/src/app/tabs/sessions/session.page.ts index b7fc961..36025ee 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'; @@ -28,6 +29,7 @@ export class SessionPage implements OnInit { private loading: IDEALoadingService, private message: IDEAMessageService, public _sessions: SessionsService, + private _sessionRegistrations: SessionRegistrationsService, public t: IDEATranslationsService, public app: AppService ) {} @@ -128,4 +130,15 @@ export class SessionPage implements OnInit { }); await modal.present(); } + + 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/sessions.page.html b/front-end/src/app/tabs/sessions/sessions.page.html index 3f08778..2b82b05 100644 --- a/front-end/src/app/tabs/sessions/sessions.page.html +++ b/front-end/src/app/tabs/sessions/sessions.page.html @@ -91,13 +91,22 @@ (favorite)="toggleFavorite($event, selectedSession)" (register)="toggleRegister($event, selectedSession)" > - - - - - - + @if(selectedSession && app.user.permissions.canManageContents) { + + + + + + + + } diff --git a/front-end/src/app/tabs/sessions/sessions.page.ts b/front-end/src/app/tabs/sessions/sessions.page.ts index 7307b53..97a2acc 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'; @@ -32,6 +33,7 @@ export class SessionsPage { private loading: IDEALoadingService, private message: IDEAMessageService, public _sessions: SessionsService, + private _sessionRegistrations: SessionRegistrationsService, public t: IDEATranslationsService, public app: AppService ) {} @@ -150,4 +152,18 @@ export class SessionsPage { }); await modal.present(); } + + 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/assets/i18n/en.json b/front-end/src/assets/i18n/en.json index e470221..ec6de7c 100644 --- a/front-end/src/assets/i18n/en.json +++ b/front-end/src/assets/i18n/en.json @@ -355,7 +355,8 @@ "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" }, "STRIPE": { "BEFORE_YOU_PROCEED": "Before you proceed", @@ -437,6 +438,8 @@ "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" } } From ad42379e2bb22965649376d579bf265dab049172 Mon Sep 17 00:00:00 2001 From: Matteo Carbone Date: Sun, 24 Mar 2024 10:51:12 +0000 Subject: [PATCH 4/9] refactor: prettier on save #81 --- back-end/src/models/user.model.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/back-end/src/models/user.model.ts b/back-end/src/models/user.model.ts index c4ea8b1..0a4a268 100644 --- a/back-end/src/models/user.model.ts +++ b/back-end/src/models/user.model.ts @@ -99,10 +99,10 @@ 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); } safeLoad(newData: any, safeData: any): void { @@ -250,4 +250,4 @@ export interface SocialMedia { instagram?: string; linkedIn?: string; twitter?: string; -} \ No newline at end of file +} From f087a6a7bac8d23b8abbb87a9af8320b84a045b1 Mon Sep 17 00:00:00 2001 From: Matteo Carbone Date: Sun, 24 Mar 2024 14:20:57 +0000 Subject: [PATCH 5/9] feat(contests): init #81 --- back-end/deploy/main.ts | 6 +- back-end/src/handlers/contests.ts | 164 +++++++++ back-end/src/models/contest.model.ts | 128 +++++++ back-end/src/models/user.model.ts | 9 + back-end/swagger.yaml | 133 ++++++++ front-end/angular.json | 6 +- front-end/package-lock.json | 29 +- front-end/package.json | 1 + .../src/app/common/datetimeWithTimezone.ts | 94 ++++++ .../src/app/tabs/contests/contest.page.ts | 225 ++++++++++++ .../src/app/tabs/contests/contests.module.ts | 10 + .../src/app/tabs/contests/contests.page.ts | 94 ++++++ .../tabs/contests/contests.routing.module.ts | 16 + .../src/app/tabs/contests/contests.service.ts | 102 ++++++ .../tabs/contests/manageContest.component.ts | 319 ++++++++++++++++++ .../src/app/tabs/manage/manage.page.html | 14 +- front-end/src/app/tabs/manage/manage.page.ts | 11 + front-end/src/app/tabs/menu/menu.page.html | 22 +- front-end/src/app/tabs/tabs.routing.module.ts | 8 +- front-end/src/assets/i18n/en.json | 35 +- front-end/src/global.scss | 1 - 21 files changed, 1403 insertions(+), 24 deletions(-) create mode 100644 back-end/src/handlers/contests.ts create mode 100644 back-end/src/models/contest.model.ts create mode 100644 front-end/src/app/common/datetimeWithTimezone.ts create mode 100644 front-end/src/app/tabs/contests/contest.page.ts create mode 100644 front-end/src/app/tabs/contests/contests.module.ts create mode 100644 front-end/src/app/tabs/contests/contests.page.ts create mode 100644 front-end/src/app/tabs/contests/contests.routing.module.ts create mode 100644 front-end/src/app/tabs/contests/contests.service.ts create mode 100644 front-end/src/app/tabs/contests/manageContest.component.ts diff --git a/back-end/deploy/main.ts b/back-end/deploy/main.ts index a42ba75..23546ed 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/src/handlers/contests.ts b/back-end/src/handlers/contests.ts new file mode 100644 index 0000000..bd5f969 --- /dev/null +++ b/back-end/src/handlers/contests.ts @@ -0,0 +1,164 @@ +/// +/// 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 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' }); + } + + 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.contest.publishedResults) throw new HandledError('Already public'); + + if (!this.contest.voteEndsAt || new Date().toISOString() <= this.contest.voteEndsAt) + 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/models/contest.model.ts b/back-end/src/models/contest.model.ts new file mode 100644 index 0000000..543db80 --- /dev/null +++ b/back-end/src/models/contest.model.ts @@ -0,0 +1,128 @@ +import { Resource, epochISOString } from 'idea-toolbox'; + +/** + * A contest to which people 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. + */ + enabled: boolean; + /** + * If set, the vote is active (users can vote) and 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 a 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.results = safeData.results; + if (safeData.isVoteStarted()) { + this.candidates = safeData.candidates; + } + } + + 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; + + 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/user.model.ts b/back-end/src/models/user.model.ts index 0a4a268..b5bcf7f 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); @@ -103,6 +108,8 @@ export class User extends Resource { 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[] { diff --git a/back-end/swagger.yaml b/back-end/swagger.yaml index 6b35cae..788414c 100644 --- a/back-end/swagger.yaml +++ b/back-end/swagger.yaml @@ -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: @@ -1168,6 +1170,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: @@ -1207,6 +1321,9 @@ components: Registration: type: object additionalProperties: {} + Contest: + type: object + additionalProperties: {} responses: AppStatus: @@ -1379,6 +1496,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/angular.json b/front-end/angular.json index 07e3f55..7e2f31d 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 66186b5..77bf0f4 100644 --- a/front-end/package-lock.json +++ b/front-end/package-lock.json @@ -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 08f1698..b9bc79e 100644 --- a/front-end/package.json +++ b/front-end/package.json @@ -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 0000000..5233182 --- /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 0000000..1f37233 --- /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 { + await 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 { + await 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 0000000..6f48f08 --- /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 0000000..8f00b1d --- /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 0000000..9bac7b2 --- /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 0000000..71854ff --- /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 0000000..9fbea0b --- /dev/null +++ b/front-end/src/app/tabs/contests/manageContest.component.ts @@ -0,0 +1,319 @@ +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 { + this.contest.candidates.splice(this.contest.candidates.indexOf(candidate), 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 { + await 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 86ab1cf..217f3ad 100644 --- a/front-end/src/app/tabs/manage/manage.page.html +++ b/front-end/src/app/tabs/manage/manage.page.html @@ -41,23 +41,23 @@

{{ 'MANAGE.CONTENTS' | translate }}

- + {{ 'MANAGE.VENUES' | translate }} - + {{ 'MANAGE.ROOMS' | translate }} - + {{ 'MANAGE.ORGANIZATIONS' | translate }} - + {{ 'MANAGE.SPEAKERS' | translate }} - + {{ 'MANAGE.SESSIONS' | translate }} {{ 'MANAGE.CONTENTS' | 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 19b3943..d8812fb 100644 --- a/front-end/src/app/tabs/manage/manage.page.ts +++ b/front-end/src/app/tabs/manage/manage.page.ts @@ -9,6 +9,7 @@ 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'; @@ -21,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', @@ -136,4 +138,13 @@ export class ManagePage { 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 959511f..12d7745 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/tabs.routing.module.ts b/front-end/src/app/tabs/tabs.routing.module.ts index 91e15d1..186cded 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 ec6de7c..bdfcd5d 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", @@ -356,7 +357,8 @@ "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.", - "DOWNLOAD_SESSIONS_REGISTRATIONS": "Download a spreadsheet with all the sessions registrations" + "DOWNLOAD_SESSIONS_REGISTRATIONS": "Download a spreadsheet with all the sessions registrations", + "CONTESTS": "Contests" }, "STRIPE": { "BEFORE_YOU_PROCEED": "Before you proceed", @@ -441,5 +443,34 @@ "ROOM": "Room", "DOWNLOAD_REGISTRATIONS": "Download a spreadsheet with all the session registrations", "SESSION_REGISTRATIONS": "Session registrations" + }, + "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/global.scss b/front-end/src/global.scss index 671fbd1..5659781 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; } - From 69f9a71e768ac2bf3e0c18925f332ff39345541c Mon Sep 17 00:00:00 2001 From: Matteo Carbone Date: Sun, 24 Mar 2024 15:29:53 +0000 Subject: [PATCH 6/9] fix(contests): minor fixes and improvements #81 --- back-end/src/handlers/contests.ts | 9 ++++++--- back-end/src/models/contest.model.ts | 13 +++++++------ front-end/src/app/tabs/contests/contest.page.ts | 14 +++++++------- front-end/src/app/tabs/contests/contests.page.ts | 4 ++-- .../app/tabs/contests/manageContest.component.ts | 10 ++++++---- 5 files changed, 28 insertions(+), 22 deletions(-) diff --git a/back-end/src/handlers/contests.ts b/back-end/src/handlers/contests.ts index bd5f969..71d2bd4 100644 --- a/back-end/src/handlers/contests.ts +++ b/back-end/src/handlers/contests.ts @@ -12,6 +12,7 @@ import { User } from '../models/user.model'; /// 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(); @@ -27,6 +28,7 @@ class ContestsRC extends ResourceController { constructor(event: any, callback: any) { super(event, callback, { resourceId: 'contestId' }); + if (STAGE === 'prod') this.silentLambdaLogs(); // to make the vote anonymous } protected async checkAuthBeforeRequest(): Promise { @@ -115,10 +117,11 @@ class ContestsRC extends ResourceController { 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.voteEndsAt || new Date().toISOString() <= this.contest.voteEndsAt) - throw new HandledError('Vote is not done'); + if (!this.contest.isVoteEnded()) throw new HandledError('Vote is not done'); await ddb.update({ TableName: DDB_TABLES.contests, @@ -155,7 +158,7 @@ class ContestsRC extends ResourceController { if (!this.user.permissions.canManageContents) { contests = contests.filter(c => c.enabled); contests.forEach(contest => { - if (contest.publishedResults) delete contest.results; + if (!contest.publishedResults) delete contest.results; }); } diff --git a/back-end/src/models/contest.model.ts b/back-end/src/models/contest.model.ts index 543db80..5921cb3 100644 --- a/back-end/src/models/contest.model.ts +++ b/back-end/src/models/contest.model.ts @@ -1,7 +1,7 @@ import { Resource, epochISOString } from 'idea-toolbox'; /** - * A contest to which people can vote in. + * A contest to which users can vote in. */ export class Contest extends Resource { /** @@ -13,11 +13,11 @@ export class Contest extends Resource { */ createdAt: epochISOString; /** - * Whether the contest is enabled and therefore shown in the menu. + * 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 ends at the configured timestamp. + * If set, the vote is active (users can vote), and it ends at the configured timestamp. */ voteEndsAt?: epochISOString; /** @@ -38,7 +38,7 @@ export class Contest extends Resource { candidates: ContestCandidate[]; /** * The count of votes for each of the sorted candidates. - * Note: the order of the candidates list must not change after a vote is open. + * 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[]; @@ -66,9 +66,10 @@ export class Contest extends Resource { safeLoad(newData: any, safeData: any): void { super.safeLoad(newData, safeData); this.contestId = safeData.contestId; - this.results = safeData.results; + this.createdAt = safeData.createdAt; if (safeData.isVoteStarted()) { this.candidates = safeData.candidates; + this.results = safeData.results; } } @@ -110,7 +111,7 @@ export class ContestCandidate extends Resource { * The country of the candidate. * This is particularly important beacuse, if set, users can't vote for candidates of their own countries. */ - country: string; + country: string | null; load(x: any): void { super.load(x); diff --git a/front-end/src/app/tabs/contests/contest.page.ts b/front-end/src/app/tabs/contests/contest.page.ts index 1f37233..965cf20 100644 --- a/front-end/src/app/tabs/contests/contest.page.ts +++ b/front-end/src/app/tabs/contests/contest.page.ts @@ -49,11 +49,11 @@ import { Contest } from '@models/contest.model'; {{ contest.name }} @if(contest.isVoteStarted()) { @if(contest.isVoteEnded()) { @if(contest.publishedResults) { - {{ 'CONTESTS.RESULTS' | translate }} + {{ 'CONTESTS.RESULTS' | translate }} } @else { {{ 'CONTESTS.VOTE_ENDED' | translate }} } } @else { - + {{ 'CONTESTS.VOTE_NOW_UNTIL' | translate : { deadline: (contest.voteEndsAt | dateLocale : 'short') } }} } } @else { @@ -85,12 +85,12 @@ import { Contest } from '@models/contest.model'; - } @if(contest.publishedResults) { @if( isCandidateWinnerByIndex($index)) { - + } @if(contest.publishedResults) { @if(isCandidateWinnerByIndex($index)) { + } {{ contest.results[$index] ?? 0 }} {{ 'CONTESTS.VOTES' | translate | lowercase }} @@ -161,7 +161,7 @@ export class ContestPage implements OnInit { } catch (err) { this._message.error('COMMON.NOT_FOUND'); } finally { - await this._loading.hide(); + this._loading.hide(); } } @@ -208,7 +208,7 @@ export class ContestPage implements OnInit { } catch (err) { this._message.error('COMMON.OPERATION_FAILED'); } finally { - await this._loading.hide(); + this._loading.hide(); } }; diff --git a/front-end/src/app/tabs/contests/contests.page.ts b/front-end/src/app/tabs/contests/contests.page.ts index 8f00b1d..a8ef07e 100644 --- a/front-end/src/app/tabs/contests/contests.page.ts +++ b/front-end/src/app/tabs/contests/contests.page.ts @@ -27,11 +27,11 @@ import { Contest } from '@models/contest.model'; {{ contest.name }} @if(contest.isVoteStarted()) { @if(contest.isVoteEnded()) { @if(contest.publishedResults) { - {{ 'CONTESTS.RESULTS' | translate }} + {{ 'CONTESTS.RESULTS' | translate }} } @else { {{ 'CONTESTS.VOTE_ENDED' | translate }} } } @else { - {{ 'CONTESTS.VOTE_NOW' | translate }} + {{ 'CONTESTS.VOTE_NOW' | translate }} } } } @empty { @if(contests) { diff --git a/front-end/src/app/tabs/contests/manageContest.component.ts b/front-end/src/app/tabs/contests/manageContest.component.ts index 9fbea0b..e4e6d0d 100644 --- a/front-end/src/app/tabs/contests/manageContest.component.ts +++ b/front-end/src/app/tabs/contests/manageContest.component.ts @@ -108,7 +108,7 @@ import { Contest, ContestCandidate } from '@models/contest.model';
{{ 'CONTESTS.CANDIDATE_URL' | translate }} - + + @for(country of _app.configurations.sectionCountries; track $index) { {{ country }} } @@ -154,7 +155,7 @@ import { Contest, ContestCandidate } from '@models/contest.model'; } {{ contest.results[$index] ?? 0 }} {{ 'CONTESTS.VOTES' | translate | lowercase }} @@ -281,7 +282,8 @@ export class ManageContestComponent implements OnInit { this.contest.candidates.push(new ContestCandidate()); } removeCandidate(candidate: ContestCandidate): void { - this.contest.candidates.splice(this.contest.candidates.indexOf(candidate), 1); + const candidateIndex = this.contest.candidates.indexOf(candidate); + if (candidateIndex !== -1) this.contest.candidates.splice(candidateIndex, 1); } isCandidateWinnerByIndex(candidateIndex: number): boolean { @@ -300,7 +302,7 @@ export class ManageContestComponent implements OnInit { } catch (err) { this._message.error('COMMON.OPERATION_FAILED'); } finally { - await this._loading.hide(); + this._loading.hide(); } }; From caa7922a11fbfa256936900faa0043117d0e1a6b Mon Sep 17 00:00:00 2001 From: Georg <135227061+georgfrodo@users.noreply.github.com> Date: Fri, 29 Mar 2024 15:16:49 +0100 Subject: [PATCH 7/9] feat: session feedback #129 --- back-end/src/handlers/sessions.ts | 66 ++++++++++++++++++- back-end/src/models/session.model.ts | 10 +++ .../src/models/sessionRegistration.model.ts | 4 ++ back-end/swagger.yaml | 32 +++++++++ .../src/app/tabs/sessions/session.page.html | 1 + .../src/app/tabs/sessions/session.page.ts | 31 ++++++++- .../sessions/sessionDetail.component.html | 47 +++++++++++++ .../tabs/sessions/sessionDetail.component.ts | 5 ++ .../src/app/tabs/sessions/sessions.page.html | 1 + .../src/app/tabs/sessions/sessions.page.ts | 29 +++++++- .../src/app/tabs/sessions/sessions.service.ts | 11 ++++ front-end/src/assets/i18n/en.json | 6 +- 12 files changed, 237 insertions(+), 6 deletions(-) diff --git a/back-end/src/handlers/sessions.ts b/back-end/src/handlers/sessions.ts index 1002aa7..666467c 100644 --- a/back-end/src/handlers/sessions.ts +++ b/back-end/src/handlers/sessions.ts @@ -18,7 +18,8 @@ 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(); @@ -55,6 +56,10 @@ class SessionsRC extends ResourceController { } protected async getResource(): Promise { + if (!this.user.permissions.canManageContents) { + delete this.session.feedbackResults; + delete this.session.feedbackComments; + } return this.session; } @@ -92,6 +97,52 @@ class SessionsRC extends ResourceController { } } + 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 { + const sessionRegistration = await ddb.get({ + TableName: DDB_TABLES.registrations, + Key: { sessionId: this.session.sessionId, userId: this.user.userId } + }); + + 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 } as Record + }; + if (comment) { + addUserRatingToSession.UpdateExpression += ', feedbackComments = list_append(feedbackComments, :comment)'; + addUserRatingToSession.ExpressionAttributeValues[':comment'] = [comment]; + } + + 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 }]); + } + protected async deleteResource(): Promise { if (!this.user.permissions.canManageContents) throw new HandledError('Unauthorized'); @@ -103,6 +154,8 @@ class SessionsRC extends ResourceController { this.session = new Session(this.body); this.session.sessionId = await ddb.IUNID(PROJECT); + this.session.feedbackResults = [0,0,0,0,0]; + this.session.feedbackComments = []; return await this.putSafeResource({ noOverwrite: true }); } @@ -110,12 +163,19 @@ class SessionsRC extends ResourceController { protected async getResources(): Promise { const sessions = (await ddb.scan({ TableName: DDB_TABLES.sessions })).map(x => new Session(x)); - const filtertedSessions = sessions.filter( + 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 filtertedSessions.sort((a, b): number => a.startsAt.localeCompare(b.startsAt)); + 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/session.model.ts b/back-end/src/models/session.model.ts index 6d45f2e..ea81d64 100644 --- a/back-end/src/models/session.model.ts +++ b/back-end/src/models/session.model.ts @@ -61,6 +61,16 @@ 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); diff --git a/back-end/src/models/sessionRegistration.model.ts b/back-end/src/models/sessionRegistration.model.ts index 666a115..39aa1b3 100644 --- a/back-end/src/models/sessionRegistration.model.ts +++ b/back-end/src/models/sessionRegistration.model.ts @@ -23,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); diff --git a/back-end/swagger.yaml b/back-end/swagger.yaml index 788414c..4327c5b 100644 --- a/back-end/swagger.yaml +++ b/back-end/swagger.yaml @@ -1034,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 diff --git a/front-end/src/app/tabs/sessions/session.page.html b/front-end/src/app/tabs/sessions/session.page.html index 48fcc9e..93cb9e3 100644 --- a/front-end/src/app/tabs/sessions/session.page.html +++ b/front-end/src/app/tabs/sessions/session.page.html @@ -7,6 +7,7 @@ [isUserRegisteredInSession]="isUserRegisteredInSession(session)" (favorite)="toggleFavorite($event, session)" (register)="toggleRegister($event, session)" + (giveFeedback)="onGiveFeedback($event, session)" > diff --git a/front-end/src/app/tabs/sessions/session.page.ts b/front-end/src/app/tabs/sessions/session.page.ts index 36025ee..e951d42 100644 --- a/front-end/src/app/tabs/sessions/session.page.ts +++ b/front-end/src/app/tabs/sessions/session.page.ts @@ -21,6 +21,7 @@ export class SessionPage implements OnInit { session: Session; favoriteSessionsIds: string[] = []; registeredSessionsIds: string[] = []; + ratedSessionsIds: string[] = []; selectedSession: Session; constructor( @@ -47,7 +48,9 @@ export class SessionPage implements OnInit { // @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); + 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 { @@ -81,6 +84,14 @@ 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(); try { @@ -110,6 +121,24 @@ 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) { + this.message.error('SESSIONS.NO_RATING'); + return; + } + await this._sessions.giveFeedback(session, rating, comment); + this.ratedSessionsIds.push(session.sessionId); + } catch (error) { + this.message.error('COMMON.OPERATION_FAILED'); + } finally { + this.loading.hide(); + } + } + async manageSession(): Promise { if (!this.session) return; diff --git a/front-end/src/app/tabs/sessions/sessionDetail.component.html b/front-end/src/app/tabs/sessions/sessionDetail.component.html index 1116a88..ae18740 100644 --- a/front-end/src/app/tabs/sessions/sessionDetail.component.html +++ b/front-end/src/app/tabs/sessions/sessionDetail.component.html @@ -20,6 +20,53 @@ {{ '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) }} diff --git a/front-end/src/app/tabs/sessions/sessionDetail.component.ts b/front-end/src/app/tabs/sessions/sessionDetail.component.ts index 2dbd5c3..1ff1a85 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 2b82b05..7e77e28 100644 --- a/front-end/src/app/tabs/sessions/sessions.page.html +++ b/front-end/src/app/tabs/sessions/sessions.page.html @@ -90,6 +90,7 @@ [isUserRegisteredInSession]="isUserRegisteredInSession(selectedSession)" (favorite)="toggleFavorite($event, selectedSession)" (register)="toggleRegister($event, selectedSession)" + (giveFeedback)="onGiveFeedback($event, selectedSession)" > @if(selectedSession && app.user.permissions.canManageContents) { diff --git a/front-end/src/app/tabs/sessions/sessions.page.ts b/front-end/src/app/tabs/sessions/sessions.page.ts index 97a2acc..7c6b238 100644 --- a/front-end/src/app/tabs/sessions/sessions.page.ts +++ b/front-end/src/app/tabs/sessions/sessions.page.ts @@ -24,6 +24,7 @@ export class SessionsPage { sessions: Session[]; favoriteSessionsIds: string[] = []; registeredSessionsIds: string[] = []; + ratedSessionsIds: string[] = []; selectedSession: Session; segment = ''; @@ -49,7 +50,9 @@ export class SessionsPage { 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); + 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'); @@ -93,6 +96,14 @@ 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(); try { @@ -131,6 +142,22 @@ export class SessionsPage { else this.selectedSession = session; } + async onGiveFeedback(ev: any, session: Session ): Promise { + try { + await this.loading.show(); + if (ev.rating === 0) { + this.message.error('SESSIONS.NO_RATING'); + return; + } + await this._sessions.giveFeedback(session, ev.rating, ev.comment); + this.ratedSessionsIds.push(session.sessionId); + } catch (error) { + this.message.error('COMMON.OPERATION_FAILED'); + } finally { + this.loading.hide(); + } + } + async manageSession(): Promise { if (!this.selectedSession) return; diff --git a/front-end/src/app/tabs/sessions/sessions.service.ts b/front-end/src/app/tabs/sessions/sessions.service.ts index c4b2a4f..04b5146 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/assets/i18n/en.json b/front-end/src/assets/i18n/en.json index bdfcd5d..1316512 100644 --- a/front-end/src/assets/i18n/en.json +++ b/front-end/src/assets/i18n/en.json @@ -442,7 +442,11 @@ "ADD_SPEAKER": "Add speaker", "ROOM": "Room", "DOWNLOAD_REGISTRATIONS": "Download a spreadsheet with all the session registrations", - "SESSION_REGISTRATIONS": "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" }, "CONTESTS": { "MANAGE_CONTEST": "Manage contest", From 0df069552b82bc9ce079f7448a4622d7b9473b0e Mon Sep 17 00:00:00 2001 From: rbento1096 Date: Tue, 2 Apr 2024 00:04:36 +0100 Subject: [PATCH 8/9] fix: review changes #129 --- .gitignore | 3 +- back-end/src/handlers/sessions.ts | 44 +- back-end/src/models/session.model.ts | 15 +- .../src/models/sessionRegistration.model.ts | 1 + .../src/app/tabs/sessions/session.page.html | 2 + .../src/app/tabs/sessions/session.page.ts | 17 +- .../sessions/sessionDetail.component.html | 247 +++++----- .../src/app/tabs/sessions/sessions.page.html | 2 + .../src/app/tabs/sessions/sessions.page.ts | 17 +- front-end/src/assets/i18n/en.json | 7 +- scripts/README.md | 6 + scripts/package-lock.json | 454 ++++++++++++++++++ scripts/package.json | 18 + scripts/src/sessionFeedback.ts | 66 +++ scripts/src/utils/ddb.utils.ts | 54 +++ scripts/tsconfig.json | 8 + 16 files changed, 804 insertions(+), 157 deletions(-) create mode 100644 scripts/README.md create mode 100644 scripts/package-lock.json create mode 100644 scripts/package.json create mode 100644 scripts/src/sessionFeedback.ts create mode 100644 scripts/src/utils/ddb.utils.ts create mode 100644 scripts/tsconfig.json diff --git a/.gitignore b/.gitignore index 36052cc..78548fd 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/src/handlers/sessions.ts b/back-end/src/handlers/sessions.ts index 666467c..7f5c0e4 100644 --- a/back-end/src/handlers/sessions.ts +++ b/back-end/src/handlers/sessions.ts @@ -8,6 +8,7 @@ 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 @@ -19,7 +20,7 @@ const DDB_TABLES = { sessions: process.env.DDB_TABLE_sessions, rooms: process.env.DDB_TABLE_rooms, speakers: process.env.DDB_TABLE_speakers, - registrations: process.env.DDB_TABLE_registrations, + registrations: process.env.DDB_TABLE_registrations }; const ddb = new DynamoDB(); @@ -56,7 +57,7 @@ class SessionsRC extends ResourceController { } protected async getResource(): Promise { - if (!this.user.permissions.canManageContents) { + if (!this.user.permissions.canManageContents || !this.user.permissions.isAdmin) { delete this.session.feedbackResults; delete this.session.feedbackComments; } @@ -107,18 +108,24 @@ class SessionsRC extends ResourceController { } private async userFeedback(rating: number, comment?: string): Promise { - const sessionRegistration = await ddb.get({ - TableName: DDB_TABLES.registrations, - Key: { sessionId: this.session.sessionId, userId: this.user.userId } - }); - - if (!sessionRegistration) { + let sessionRegistration: SessionRegistration; + try { + 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.hasUserRated) throw new HandledError("Already rated this session"); + 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 (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'); @@ -126,12 +133,8 @@ class SessionsRC extends ResourceController { TableName: DDB_TABLES.sessions, Key: { sessionId: this.session.sessionId }, UpdateExpression: `ADD feedbackResults[${rating - 1}] :one`, - ExpressionAttributeValues: { ':one': 1 } as Record + ExpressionAttributeValues: { ':one': 1 } }; - if (comment) { - addUserRatingToSession.UpdateExpression += ', feedbackComments = list_append(feedbackComments, :comment)'; - addUserRatingToSession.ExpressionAttributeValues[':comment'] = [comment]; - } const setHasUserRated = { TableName: DDB_TABLES.registrations, @@ -141,6 +144,15 @@ class SessionsRC extends ResourceController { }; 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 { @@ -154,8 +166,6 @@ class SessionsRC extends ResourceController { this.session = new Session(this.body); this.session.sessionId = await ddb.IUNID(PROJECT); - this.session.feedbackResults = [0,0,0,0,0]; - this.session.feedbackComments = []; return await this.putSafeResource({ noOverwrite: true }); } diff --git a/back-end/src/models/session.model.ts b/back-end/src/models/session.model.ts index ea81d64..9b8793b 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. @@ -71,7 +76,6 @@ export class Session extends Resource { */ feedbackComments?: string[]; - load(x: any): void { super.load(x); this.sessionId = this.clean(x.sessionId, String); @@ -91,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); @@ -126,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 39aa1b3..144986f 100644 --- a/back-end/src/models/sessionRegistration.model.ts +++ b/back-end/src/models/sessionRegistration.model.ts @@ -35,6 +35,7 @@ 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); } /** diff --git a/front-end/src/app/tabs/sessions/session.page.html b/front-end/src/app/tabs/sessions/session.page.html index 93cb9e3..e7fef14 100644 --- a/front-end/src/app/tabs/sessions/session.page.html +++ b/front-end/src/app/tabs/sessions/session.page.html @@ -5,6 +5,8 @@ [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)" diff --git a/front-end/src/app/tabs/sessions/session.page.ts b/front-end/src/app/tabs/sessions/session.page.ts index e951d42..a4aee1b 100644 --- a/front-end/src/app/tabs/sessions/session.page.ts +++ b/front-end/src/app/tabs/sessions/session.page.ts @@ -121,19 +121,24 @@ export class SessionPage implements OnInit { } } - async onGiveFeedback(ev: any, session: Session ): Promise { + async onGiveFeedback(ev: any, session: Session): Promise { try { await this.loading.show(); let rating = ev.rating; let comment = ev.comment; - if (rating === 0) { - this.message.error('SESSIONS.NO_RATING'); - return; - } + 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) { - this.message.error('COMMON.OPERATION_FAILED'); + 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(); } diff --git a/front-end/src/app/tabs/sessions/sessionDetail.component.html b/front-end/src/app/tabs/sessions/sessionDetail.component.html index ae18740..9977a5e 100644 --- a/front-end/src/app/tabs/sessions/sessionDetail.component.html +++ b/front-end/src/app/tabs/sessions/sessionDetail.component.html @@ -1,132 +1,135 @@ - - - - - {{ 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 }}) - - - - + + + + + {{ 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/sessions.page.html b/front-end/src/app/tabs/sessions/sessions.page.html index 7e77e28..4211f42 100644 --- a/front-end/src/app/tabs/sessions/sessions.page.html +++ b/front-end/src/app/tabs/sessions/sessions.page.html @@ -88,6 +88,8 @@ [session]="selectedSession" [isSessionInFavorites]="isSessionInFavorites(selectedSession)" [isUserRegisteredInSession]="isUserRegisteredInSession(selectedSession)" + [hasUserRatedSession]="hasUserRatedSession(selectedSession)" + [hasSessionEnded]="hasSessionEnded(selectedSession)" (favorite)="toggleFavorite($event, selectedSession)" (register)="toggleRegister($event, selectedSession)" (giveFeedback)="onGiveFeedback($event, selectedSession)" diff --git a/front-end/src/app/tabs/sessions/sessions.page.ts b/front-end/src/app/tabs/sessions/sessions.page.ts index 7c6b238..a87be45 100644 --- a/front-end/src/app/tabs/sessions/sessions.page.ts +++ b/front-end/src/app/tabs/sessions/sessions.page.ts @@ -142,17 +142,22 @@ export class SessionsPage { else this.selectedSession = session; } - async onGiveFeedback(ev: any, session: Session ): Promise { + async onGiveFeedback(ev: any, session: Session): Promise { try { await this.loading.show(); - if (ev.rating === 0) { - this.message.error('SESSIONS.NO_RATING'); - return; - } + 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) { - this.message.error('COMMON.OPERATION_FAILED'); + 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(); } diff --git a/front-end/src/assets/i18n/en.json b/front-end/src/assets/i18n/en.json index 1316512..b3213ea 100644 --- a/front-end/src/assets/i18n/en.json +++ b/front-end/src/assets/i18n/en.json @@ -446,7 +446,12 @@ "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" + "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", diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..8c23d4c --- /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 0000000..f762052 --- /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 0000000..f659779 --- /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 0000000..52f8697 --- /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 0000000..cde7251 --- /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 0000000..767c359 --- /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"] +} From c3e46b796a86f0ac18b28176119646e37c8701ab Mon Sep 17 00:00:00 2001 From: Matteo Carbone Date: Fri, 5 Apr 2024 09:19:07 +0200 Subject: [PATCH 9/9] v3.5.0 --- back-end/package-lock.json | 4 ++-- back-end/package.json | 2 +- back-end/swagger.yaml | 2 +- front-end/android/app/build.gradle | 2 +- front-end/package-lock.json | 4 ++-- front-end/package.json | 2 +- front-end/src/environments/environment.idea.ts | 2 +- 7 files changed, 9 insertions(+), 9 deletions(-) diff --git a/back-end/package-lock.json b/back-end/package-lock.json index 3f05288..18bba1c 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 ebfdb88..7800198 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/swagger.yaml b/back-end/swagger.yaml index 4327c5b..9f9d37b 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 diff --git a/front-end/android/app/build.gradle b/front-end/android/app/build.gradle index fcab200..ddcf5b7 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/package-lock.json b/front-end/package-lock.json index 77bf0f4..c95e8dc 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", diff --git a/front-end/package.json b/front-end/package.json index b9bc79e..44c5d57 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": { diff --git a/front-end/src/environments/environment.idea.ts b/front-end/src/environments/environment.idea.ts index b98d46c..d4ab1f2 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',