diff --git a/back-end/src/handlers/registrations.ts b/back-end/src/handlers/registrations.ts index 77bea17..83520f9 100644 --- a/back-end/src/handlers/registrations.ts +++ b/back-end/src/handlers/registrations.ts @@ -7,6 +7,7 @@ import { DynamoDB, HandledError, ResourceController } from 'idea-aws'; import { Session } from '../models/session.model'; import { SessionRegistration } from '../models/sessionRegistration.model'; import { User } from '../models/user.model'; +import { Configurations } from '../models/configurations.model'; /// /// CONSTANTS, ENVIRONMENT VARIABLES, HANDLER @@ -15,7 +16,9 @@ import { User } from '../models/user.model'; const DDB_TABLES = { users: process.env.DDB_TABLE_users, sessions: process.env.DDB_TABLE_sessions, - registrations: process.env.DDB_TABLE_registrations + configurations: process.env.DDB_TABLE_configurations, + registrations: process.env.DDB_TABLE_registrations, + usersFavoriteSessions: process.env.DDB_TABLE_usersFavoriteSessions }; const ddb = new DynamoDB(); @@ -28,6 +31,7 @@ export const handler = (ev: any, _: any, cb: any) => new SessionRegistrations(ev class SessionRegistrations extends ResourceController { user: User; + configurations: Configurations; registration: SessionRegistration; constructor(event: any, callback: any) { @@ -35,12 +39,22 @@ class SessionRegistrations extends ResourceController { } 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'); } + try { + this.configurations = new Configurations( + await ddb.get({ TableName: DDB_TABLES.configurations, Key: { PK: Configurations.PK } }) + ); + } catch (err) { + throw new HandledError('Configuration not found'); + } + if (!this.resourceId || this.httpMethod === 'POST') return; try { @@ -72,13 +86,15 @@ class SessionRegistrations extends ResourceController { } } - protected async postResources(): Promise { - // @todo configurations.canSignUpForSessions() + protected async postResource(): Promise { + if (!this.configurations.areSessionRegistrationsOpen) throw new HandledError('Registrations are closed!') this.registration = new SessionRegistration({ sessionId: this.resourceId, - userId: this.principalId, - registrationDateInMs: new Date().getTime() + userId: this.user.userId, + registrationDateInMs: new Date().getTime(), + name: this.user.getName(), + sectionCountry: this.user.sectionCountry }); return await this.putSafeResource(); @@ -89,7 +105,7 @@ class SessionRegistrations extends ResourceController { } protected async deleteResource(): Promise { - // @todo configurations.canSignUpForSessions() + if (!this.configurations.areSessionRegistrationsOpen) throw new HandledError('Registrations are closed!') try { const { sessionId, userId } = this.registration; @@ -105,7 +121,16 @@ class SessionRegistrations extends ResourceController { } }; - await ddb.transactWrites([{ Delete: deleteSessionRegistration }, { Update: updateSessionCount }]); + 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'); } @@ -113,7 +138,8 @@ class SessionRegistrations extends ResourceController { private async putSafeResource(): Promise { const { sessionId, userId } = this.registration; - const isValid = await this.validateRegistration(sessionId, userId); + const session: 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!"); @@ -124,12 +150,23 @@ class SessionRegistrations extends ResourceController { TableName: DDB_TABLES.sessions, Key: { sessionId }, UpdateExpression: 'ADD numberOfParticipants :one', + ConditionExpression: 'numberOfParticipants < :limit', ExpressionAttributeValues: { - ':one': 1 + ':one': 1, + ":limit": session.limitOfParticipants } }; - await ddb.transactWrites([{ Put: putSessionRegistration }, { Update: updateSessionCount }]); + 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) { @@ -137,9 +174,7 @@ class SessionRegistrations extends ResourceController { } } - private async validateRegistration(sessionId: string, userId: string) { - const session: Session = new Session(await ddb.get({ TableName: DDB_TABLES.sessions, Key: { sessionId } })); - + private async validateRegistration(session: Session, userId: string) { 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.'); @@ -159,8 +194,14 @@ class SessionRegistrations extends ResourceController { const sessionStartDate = s.calcDatetimeWithoutTimezone(s.startsAt); const sessionEndDate = s.calcDatetimeWithoutTimezone(s.endsAt); - const targetSessionStart = session.calcDatetimeWithoutTimezone(session.startsAt); - const targetSessionEnd = session.calcDatetimeWithoutTimezone(session.endsAt); + const targetSessionStart = session.calcDatetimeWithoutTimezone( + session.startsAt, + -1 * this.configurations.sessionRegistrationBuffer || 0 + ); + const targetSessionEnd = session.calcDatetimeWithoutTimezone( + session.endsAt, + this.configurations.sessionRegistrationBuffer || 0 + ); // it's easier to prove a session is valid than it is to prove it's invalid. (1 vs 5 conditional checks) return sessionStartDate >= targetSessionEnd || sessionEndDate <= targetSessionStart; diff --git a/back-end/src/handlers/sessions.ts b/back-end/src/handlers/sessions.ts index 13ce2d1..bd84097 100644 --- a/back-end/src/handlers/sessions.ts +++ b/back-end/src/handlers/sessions.ts @@ -69,20 +69,20 @@ class Sessions extends ResourceController { return await this.putSafeResource(); } private async putSafeResource(opts: { noOverwrite?: boolean } = {}): Promise { - const errors = this.session.validate(); - if (errors.length) throw new HandledError(`Invalid fields: ${errors.join(', ')}`); - this.session.room = new RoomLinked( await ddb.get({ TableName: DDB_TABLES.rooms, Key: { roomId: this.session.room.roomId } }) ); - this.session.speakers = ( - await ddb.batchGet( - DDB_TABLES.speakers, - this.session.speakers?.map(speakerId => ({ speakerId })), - true - ) - ).map(s => new SpeakerLinked(s)); + const getSpeakers = 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(); + if (errors.length) throw new HandledError(`Invalid fields: ${errors.join(', ')}`); try { const putParams: any = { TableName: DDB_TABLES.sessions, Item: this.session }; diff --git a/back-end/src/models/configurations.model.ts b/back-end/src/models/configurations.model.ts index 78965f0..ac02001 100644 --- a/back-end/src/models/configurations.model.ts +++ b/back-end/src/models/configurations.model.ts @@ -5,6 +5,7 @@ import { User } from '../models/user.model'; import { ServiceLanguages } from './serviceLanguages.enum'; export const LANGUAGES = new Languages({ default: ServiceLanguages.English, available: [ServiceLanguages.English] }); +const DEFAULT_SESSION_REGISTRATION_BUFFER_MINUTES = 10; export class Configurations extends Resource { static PK = 'EGM'; @@ -21,6 +22,14 @@ export class Configurations extends Resource { * Whether externals and guests can register. */ isRegistrationOpenForExternals: boolean; + /** + * Whether participants can register for sessions. + */ + areSessionRegistrationsOpen: boolean; + /** + * The minimum amount of time (in minutes) a user must leave open between sessions. + */ + sessionRegistrationBuffer: number; /** * Whether the delegation leaders can assign spots. */ @@ -55,6 +64,12 @@ export class Configurations extends Resource { this.PK = Configurations.PK; this.isRegistrationOpenForESNers = this.clean(x.isRegistrationOpenForESNers, Boolean); this.isRegistrationOpenForExternals = this.clean(x.isRegistrationOpenForExternals, Boolean); + this.areSessionRegistrationsOpen = this.clean(x.areSessionRegistrationsOpen, Boolean); + this.sessionRegistrationBuffer = this.clean( + x.sessionRegistrationBuffer, + Number, + DEFAULT_SESSION_REGISTRATION_BUFFER_MINUTES + ); this.canCountryLeadersAssignSpots = this.clean(x.canCountryLeadersAssignSpots, Boolean); this.registrationFormDef = new CustomBlockMeta(x.registrationFormDef, LANGUAGES); this.currency = this.clean(x.currency, String); @@ -76,6 +91,7 @@ export class Configurations extends Resource { validate(): string[] { const e = super.validate(); this.registrationFormDef.validate(LANGUAGES).forEach(ea => e.push(`registrationFormDef.${ea}`)); + if (this.sessionRegistrationBuffer < 0) e.push('sessionRegistrationBuffer') return e; } diff --git a/back-end/src/models/organization.model.ts b/back-end/src/models/organization.model.ts index b0aa1f7..cfcb442 100644 --- a/back-end/src/models/organization.model.ts +++ b/back-end/src/models/organization.model.ts @@ -25,10 +25,6 @@ export class Organization extends Resource { * The organization's contact email. */ contactEmail: string; - /** - * A link to perform a contact action. - */ - contactAction: string; // @todo check this load(x: any): void { super.load(x); @@ -38,7 +34,6 @@ export class Organization extends Resource { this.description = this.clean(x.description, String); this.website = this.clean(x.website, String); this.contactEmail = this.clean(x.contactEmail, String); - this.contactAction = this.clean(x.contactAction, String); } safeLoad(newData: any, safeData: any): void { super.safeLoad(newData, safeData); @@ -47,13 +42,10 @@ export class Organization extends Resource { validate(): string[] { const e = super.validate(); if (isEmpty(this.name)) e.push('name'); - if (this.website && isEmpty(this.website, 'url')) e.push('website'); - if (this.contactEmail && isEmpty(this.contactEmail, 'email')) e.push('contactEmail'); return e; } } -// @todo check this export class OrganizationLinked extends Resource { organizationId: string; name: string; diff --git a/back-end/src/models/room.model.ts b/back-end/src/models/room.model.ts index 41fb0e5..a51375d 100644 --- a/back-end/src/models/room.model.ts +++ b/back-end/src/models/room.model.ts @@ -27,10 +27,6 @@ export class Room extends Resource { * An URI for an image of the room. */ imageURI: string; - /** - * An URI for a plan of the room. - */ - planImageURI: string; load(x: any): void { super.load(x); @@ -40,7 +36,6 @@ export class Room extends Resource { this.internalLocation = this.clean(x.internalLocation, String); this.description = this.clean(x.description, String); this.imageURI = this.clean(x.imageURI, String); - this.planImageURI = this.clean(x.planImageURI, String); } safeLoad(newData: any, safeData: any): void { super.safeLoad(newData, safeData); diff --git a/back-end/src/models/session.model.ts b/back-end/src/models/session.model.ts index d834171..6d45f2e 100644 --- a/back-end/src/models/session.model.ts +++ b/back-end/src/models/session.model.ts @@ -4,7 +4,7 @@ import { RoomLinked } from './room.model'; import { SpeakerLinked } from './speaker.model'; /** - * YYYY-MM-DDTHH:MM, without timezone. // @todo do we need this? + * YYYY-MM-DDTHH:MM, without timezone. */ type datetime = string; @@ -76,9 +76,11 @@ export class Session extends Resource { this.endsAt = this.calcDatetimeWithoutTimezone(endsAt); this.room = typeof x.room === 'string' ? new RoomLinked({ roomId: x.room }) : new RoomLinked(x.room); this.speakers = this.cleanArray(x.speakers, x => new SpeakerLinked(x)); - this.numberOfParticipants = this.clean(x.numberOfParticipants, Number, 0); - this.limitOfParticipants = this.clean(x.limitOfParticipants, Number); - this.requiresRegistration = Object.keys(IndividualSessionType).includes(this.type); + this.requiresRegistration = this.type !== SessionType.COMMON; + if (this.requiresRegistration) { + this.numberOfParticipants = this.clean(x.numberOfParticipants, Number, 0); + this.limitOfParticipants = this.clean(x.limitOfParticipants, Number); + } } safeLoad(newData: any, safeData: any): void { super.safeLoad(newData, safeData); @@ -92,58 +94,43 @@ export class Session extends Resource { if (isEmpty(this.durationMinutes)) e.push('durationMinutes'); if (!this.room.roomId) e.push('room'); if (!this.speakers?.length) e.push('speakers'); + if (this.requiresRegistration && !this.limitOfParticipants) e.push('limitOfParticipants'); return e; } // @todo add a method to check if a user/speaker is in the session or not - calcDatetimeWithoutTimezone(dateToFormat: Date | string | number): datetime { + calcDatetimeWithoutTimezone(dateToFormat: Date | string | number, bufferInMinutes = 0): datetime { const date = new Date(dateToFormat); - return new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().slice(0, 16); + return new Date( + date.getTime() - + this.convertMinutesToMilliseconds(date.getTimezoneOffset()) + + this.convertMinutesToMilliseconds(bufferInMinutes) + ) + .toISOString() + .slice(0, 16); + } + + convertMinutesToMilliseconds(minutes: number) { + return minutes * 60 * 1000; } isFull(): boolean { - return this.numberOfParticipants >= this.limitOfParticipants; + return this.requiresRegistration ? this.numberOfParticipants >= this.limitOfParticipants : false } -} -// @todo don't have three enums... -// @todo check if any is missing or we need to add. -export enum CommonSessionType { - OPENING = 'OPENING', - KEYNOTE = 'KEYNOTE', - MORNING = 'MORNING', - POSTER = 'POSTER', - EXPO = 'EXPO', - CANDIDATES = 'CANDIDATES', - HARVESTING = 'HARVESTING', - CLOSING = 'CLOSING', - OTHER = 'OTHER' + getSpeakers(): string { + return this.speakers.map(s => s.name).join(', ') + } } -export enum IndividualSessionType { - DISCUSSION = 'DISCUSSION', - TALK = 'TALK', - IGNITE = 'IGNITE', - CAMPFIRE = 'CAMPFIRE', - IDEAS = 'IDEAS', - INCUBATOR = 'INCUBATOR' -} export enum SessionType { - OPENING = 'OPENING', - KEYNOTE = 'KEYNOTE', - MORNING = 'MORNING', - POSTER = 'POSTER', - EXPO = 'EXPO', - CANDIDATES = 'CANDIDATES', - HARVESTING = 'HARVESTING', - CLOSING = 'CLOSING', DISCUSSION = 'DISCUSSION', TALK = 'TALK', IGNITE = 'IGNITE', CAMPFIRE = 'CAMPFIRE', - IDEAS = 'IDEAS', INCUBATOR = 'INCUBATOR', - OTHER = 'OTHER' + HUB = 'HUB', + COMMON = 'COMMON' } diff --git a/back-end/src/models/sessionRegistration.model.ts b/back-end/src/models/sessionRegistration.model.ts index 31064a6..eb1a677 100644 --- a/back-end/src/models/sessionRegistration.model.ts +++ b/back-end/src/models/sessionRegistration.model.ts @@ -13,11 +13,21 @@ export class SessionRegistration extends Resource { * The date of the registration. */ registrationDateInMs: number; + /** + * The user's name. + */ + name: string; + /** + * The user's ESN Country if any. + */ + sectionCountry?: string; load(x: any): void { super.load(x); this.sessionId = this.clean(x.sessionId, String); this.userId = this.clean(x.userId, String); 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); } } diff --git a/back-end/src/models/speaker.model.ts b/back-end/src/models/speaker.model.ts index fb74552..286e1d4 100644 --- a/back-end/src/models/speaker.model.ts +++ b/back-end/src/models/speaker.model.ts @@ -1,6 +1,7 @@ import { isEmpty, Resource } from 'idea-toolbox'; import { OrganizationLinked } from './organization.model'; +import { SocialMedia } from './user.model'; export class Speaker extends Resource { /** @@ -31,6 +32,10 @@ export class Speaker extends Resource { * The speaker's organization. */ organization: OrganizationLinked; + /** + * The speaker's social media links, if any. + */ + socialMedia: SocialMedia; static getRole(speaker: Speaker | SpeakerLinked): string { return speaker.organization.name || speaker.title @@ -50,6 +55,10 @@ export class Speaker extends Resource { typeof x.organization === 'string' ? new OrganizationLinked({ organizationId: x.organization }) : new OrganizationLinked(x.organization); + 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 { super.safeLoad(newData, safeData); @@ -58,7 +67,6 @@ export class Speaker extends Resource { validate(): string[] { const e = super.validate(); if (isEmpty(this.name)) e.push('name'); - if (this.contactEmail && isEmpty(this.contactEmail, 'email')) e.push('contactEmail'); if (!this.organization.organizationId) e.push('organization'); return e; } diff --git a/back-end/src/models/user.model.ts b/back-end/src/models/user.model.ts index a301d21..c4ea8b1 100644 --- a/back-end/src/models/user.model.ts +++ b/back-end/src/models/user.model.ts @@ -72,6 +72,10 @@ export class User extends Resource { * The spot assigned for the event, if any. */ spot?: EventSpotAttached; + /** + * The user's social media links, if any. + */ + socialMedia: SocialMedia; load(x: any): void { super.load(x); @@ -95,6 +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) } safeLoad(newData: any, safeData: any): void { @@ -237,3 +245,9 @@ export class UserFavoriteSession extends Resource { this.sessionId = this.clean(x.sessionId, String); } } + +export interface SocialMedia { + instagram?: string; + linkedIn?: string; + twitter?: string; +} \ No newline at end of file diff --git a/front-end/src/app/app.service.ts b/front-end/src/app/app.service.ts index 46af47f..316cc10 100644 --- a/front-end/src/app/app.service.ts +++ b/front-end/src/app/app.service.ts @@ -208,4 +208,13 @@ export class AppService { const p = this.user.permissions; return p.isAdmin || p.canManageRegistrations || p.canManageContents; } + + formatDateShort = (date: string | Date): string => { + if (!(date instanceof Date)) date = new Date(date); + return date.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short' }); + }; + formatTime(date: string | Date): string { + if (!(date instanceof Date)) date = new Date(date); + return new Date(date).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }); + } } diff --git a/front-end/src/app/common/htmlEditor.component.ts b/front-end/src/app/common/htmlEditor.component.ts index f5cb607..f7da1ff 100644 --- a/front-end/src/app/common/htmlEditor.component.ts +++ b/front-end/src/app/common/htmlEditor.component.ts @@ -102,6 +102,8 @@ export class HTMLEditorComponent implements OnInit, OnChanges { } ngOnChanges(changes: SimpleChanges): void { if (changes.editMode) this.sanitizedHtml = this._sanitizer.sanitize(SecurityContext.HTML, this.content); + if (changes.content) this.sanitizedHtml = this._sanitizer.sanitize(SecurityContext.HTML, this.content); + } cleanHTMLCode(): void { diff --git a/front-end/src/app/manage.guard.ts b/front-end/src/app/manage.guard.ts new file mode 100644 index 0000000..fd259d2 --- /dev/null +++ b/front-end/src/app/manage.guard.ts @@ -0,0 +1,9 @@ +import { inject } from '@angular/core'; +import { CanActivateFn } from '@angular/router'; + +import { AppService } from './app.service'; + +export const manageGuard: CanActivateFn = async (): Promise => { + const _app = inject(AppService); + return _app.userCanManageSomething() || _app.user.permissions.isCountryLeader +}; diff --git a/front-end/src/app/spot.guard.ts b/front-end/src/app/spot.guard.ts new file mode 100644 index 0000000..bdbe421 --- /dev/null +++ b/front-end/src/app/spot.guard.ts @@ -0,0 +1,10 @@ +import { inject } from '@angular/core'; +import { CanActivateFn } from '@angular/router'; + +import { AppService } from './app.service'; + +export const spotGuard: CanActivateFn = async (): Promise => { + const _app = inject(AppService); + if (_app.userCanManageSomething()) return true + else return _app.user.spot?.paymentConfirmedAt ? true : false; +}; diff --git a/front-end/src/app/tabs/manage/configurations/registrations/registrationsConfig.page.html b/front-end/src/app/tabs/manage/configurations/registrations/registrationsConfig.page.html index 9c95f88..117ec19 100644 --- a/front-end/src/app/tabs/manage/configurations/registrations/registrationsConfig.page.html +++ b/front-end/src/app/tabs/manage/configurations/registrations/registrationsConfig.page.html @@ -37,6 +37,17 @@

{{ 'MANAGE.REGISTRATIONS_OPTIONS' | translate }}

{{ 'MANAGE.ALLOW_EXTERNALS' | translate }} + + + {{ 'MANAGE.ALLOW_SESSION_REGISTRATIONS' | translate }} + + + + + {{ 'MANAGE.SESSION_BUFFER' | translate }} + + + {{ 'MANAGE.ALLOW_COUNTRY_LEADER_ASSIGN_SPOT' | translate }} diff --git a/front-end/src/app/tabs/manage/manage.page.html b/front-end/src/app/tabs/manage/manage.page.html index 7344873..4074d03 100644 --- a/front-end/src/app/tabs/manage/manage.page.html +++ b/front-end/src/app/tabs/manage/manage.page.html @@ -40,21 +40,45 @@

{{ 'MANAGE.CONTENTS' | translate }}

{{ 'MANAGE.CONTENTS_I' | translate }}

- - - {{ 'MANAGE.COMMUNICATIONS' | translate }} + + + {{ 'MANAGE.VENUES' | translate }} - - - {{ 'MANAGE.SESSIONS' | translate }} + + + {{ 'MANAGE.ROOMS' | translate }} - + + + {{ 'MANAGE.ORGANIZATIONS' | translate }} + + {{ 'MANAGE.SPEAKERS' | translate }} - - - {{ 'MANAGE.ORGANIZATIONS' | 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 0c1f3fe..aeeed4a 100644 --- a/front-end/src/app/tabs/manage/manage.page.ts +++ b/front-end/src/app/tabs/manage/manage.page.ts @@ -4,12 +4,22 @@ import { IDEALoadingService, IDEAMessageService } from '@idea-ionic/common'; import { EmailTemplateComponent } from './configurations/emailTemplate/emailTemplate.component'; import { ManageUsefulLinkStandaloneComponent } from '@app/common/usefulLinks/manageUsefulLink.component'; +import { ManageOrganizationComponent } from '../organizations/manageOrganization.component'; +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 { AppService } from '@app/app.service'; import { UsefulLinksService } from '@app/common/usefulLinks/usefulLinks.service'; import { EmailTemplates, DocumentTemplates } from '@models/configurations.model'; import { UsefulLink } from '@models/usefulLink.model'; +import { Organization } from '@models/organization.model'; +import { Venue } from '@models/venue.model'; +import { Speaker } from '@models/speaker.model'; +import { Room } from '@models/room.model'; +import { Session } from '@models/session.model'; @Component({ selector: 'manage', @@ -71,4 +81,45 @@ export class ManagePage { async addUsefulLink(): Promise { await this.editUsefulLink(new UsefulLink()); } + + async addOrganization(): Promise { + const modal = await this.modalCtrl.create({ + component: ManageOrganizationComponent, + componentProps: { organization: new Organization() }, + backdropDismiss: false + }); + await modal.present(); + } + async addSpeaker(): Promise { + const modal = await this.modalCtrl.create({ + component: ManageSpeakerComponent, + componentProps: { speaker: new Speaker() }, + backdropDismiss: false + }); + await modal.present(); + } + async addVenue(): Promise { + const modal = await this.modalCtrl.create({ + component: ManageVenueComponent, + componentProps: { venue: new Venue() }, + backdropDismiss: false + }); + await modal.present(); + } + async addRoom(): Promise { + const modal = await this.modalCtrl.create({ + component: ManageRoomComponent, + componentProps: { room: new Room() }, + backdropDismiss: false + }); + await modal.present(); + } + async addSession(): Promise { + const modal = await this.modalCtrl.create({ + component: ManageSessionComponent, + componentProps: { session: new Session() }, + 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 9a398a1..959511f 100644 --- a/front-end/src/app/tabs/menu/menu.page.html +++ b/front-end/src/app/tabs/menu/menu.page.html @@ -14,7 +14,7 @@ {{ 'MENU.HOME' | translate }} - + {{ 'MENU.AGENDA' | translate }} diff --git a/front-end/src/app/tabs/organizations/manageOrganization.component.ts b/front-end/src/app/tabs/organizations/manageOrganization.component.ts new file mode 100644 index 0000000..a239b97 --- /dev/null +++ b/front-end/src/app/tabs/organizations/manageOrganization.component.ts @@ -0,0 +1,187 @@ +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { Component, Input, OnInit } from '@angular/core'; +import { AlertController, IonicModule, ModalController } from '@ionic/angular'; +import { + IDEALoadingService, + IDEAMessageService, + IDEATranslationsModule, + IDEATranslationsService +} from '@idea-ionic/common'; + +import { HTMLEditorComponent } from 'src/app/common/htmlEditor.component'; + +import { AppService } from '@app/app.service'; +import { MediaService } from 'src/app/common/media.service'; + +import { Organization } from '@models/organization.model'; +import { OrganizationsService } from './organizations.service'; + +@Component({ + standalone: true, + imports: [CommonModule, FormsModule, IonicModule, IDEATranslationsModule, HTMLEditorComponent], + selector: 'app-manage-organization', + template: ` + + + + + + + + {{ 'ORGANIZATIONS.MANAGE_ORGANIZATION' | translate }} + + + + + + + + + + + + {{ 'ORGANIZATIONS.NAME' | translate }} + + + + + {{ 'ORGANIZATIONS.IMAGE_URL' | translate }} + + + + + + + + + {{ 'ORGANIZATIONS.WEBSITE' | translate }} + + + + + + {{ 'ORGANIZATIONS.EMAIL' | translate }} + + + + + +

{{ 'ORGANIZATION.DESCRIPTION' | translate }}

+
+
+ + + + {{ 'COMMON.DELETE' | translate }} + + +
+
+ ` +}) +export class ManageOrganizationComponent implements OnInit { + /** + * The organization to manage. + */ + @Input() organization: Organization; + + entityBeforeChange: Organization + + errors = new Set(); + + constructor( + private modalCtrl: ModalController, + private alertCtrl: AlertController, + private t: IDEATranslationsService, + private loading: IDEALoadingService, + private message: IDEAMessageService, + private _media: MediaService, + private _organizations: OrganizationsService, + public app: AppService + ) {} + + ngOnInit() { + this.entityBeforeChange = new Organization(this.organization) + } + + hasFieldAnError(field: string): boolean { + return this.errors.has(field); + } + + browseImagesForElementId(elementId: string): void { + document.getElementById(elementId).click(); + } + 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.organization.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.organization.validate()); + if (this.errors.size) return this.message.error('COMMON.FORM_HAS_ERROR_TO_CHECK'); + + try { + await this.loading.show(); + let result: Organization; + if (!this.organization.organizationId) result = await this._organizations.insert(this.organization); + else result = await this._organizations.update(this.organization); + this.organization.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.organization = this.entityBeforeChange + this.modalCtrl.dismiss(); + } + + async askAndDelete(): Promise { + const doDelete = async (): Promise => { + try { + await this.loading.show(); + await this._organizations.delete(this.organization); + this.message.success('COMMON.OPERATION_COMPLETED'); + this.close(); + this.app.goToInTabs(['organizations']) + } 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.alertCtrl.create({ header, message, 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/organizations/organization.page.html b/front-end/src/app/tabs/organizations/organization.page.html index fb9b3b9..582e02f 100644 --- a/front-end/src/app/tabs/organizations/organization.page.html +++ b/front-end/src/app/tabs/organizations/organization.page.html @@ -1,6 +1,18 @@ + + + + + {{ 'ORGANIZATIONS.DETAILS' | translate }} + + + + + + + diff --git a/front-end/src/app/tabs/organizations/organization.page.ts b/front-end/src/app/tabs/organizations/organization.page.ts index 8431caf..f7f33d5 100644 --- a/front-end/src/app/tabs/organizations/organization.page.ts +++ b/front-end/src/app/tabs/organizations/organization.page.ts @@ -1,8 +1,11 @@ import { Component, OnInit } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; +import { ModalController } from '@ionic/angular'; import { IDEALoadingService, IDEAMessageService } from '@idea-ionic/common'; +import { ManageOrganizationComponent } from './manageOrganization.component'; + import { AppService } from 'src/app/app.service'; import { OrganizationsService } from './organizations.service'; import { SpeakersService } from '../speakers/speakers.service'; @@ -21,6 +24,7 @@ export class OrganizationPage implements OnInit { constructor( private route: ActivatedRoute, + private modalCtrl: ModalController, private loading: IDEALoadingService, private message: IDEAMessageService, private _organizations: OrganizationsService, @@ -37,7 +41,7 @@ export class OrganizationPage implements OnInit { await this.loading.show(); const organizationId = this.route.snapshot.paramMap.get('organizationId'); this.organization = await this._organizations.getById(organizationId); - this.speakers = await this._speakers.getList({ organization: this.organization.organizationId, force: true }); + this.speakers = await this._speakers.getOrganizationSpeakers(this.organization.organizationId); } catch (err) { this.message.error('COMMON.NOT_FOUND'); } finally { @@ -46,6 +50,20 @@ export class OrganizationPage implements OnInit { } async filterSpeakers(search: string = ''): Promise { - this.speakers = await this._speakers.getList({ search, organization: this.organization.organizationId }); + this.speakers = await this._speakers.getOrganizationSpeakers(this.organization.organizationId, search); + } + + async manageOrganization(organization: Organization): Promise { + if (!this.app.user.permissions.canManageContents) return + + const modal = await this.modalCtrl.create({ + component: ManageOrganizationComponent, + componentProps: { organization }, + backdropDismiss: false + }); + modal.onDidDismiss().then(async (): Promise => { + this.organization = await this._organizations.getById(organization.organizationId); + }); + await modal.present(); } } diff --git a/front-end/src/app/tabs/organizations/organizationCard.component.ts b/front-end/src/app/tabs/organizations/organizationCard.component.ts index 6e1603a..f320023 100644 --- a/front-end/src/app/tabs/organizations/organizationCard.component.ts +++ b/front-end/src/app/tabs/organizations/organizationCard.component.ts @@ -5,13 +5,15 @@ import { IonicModule } from '@ionic/angular'; import { IDEATranslationsModule } from '@idea-ionic/common'; +import { HTMLEditorComponent } from 'src/app/common/htmlEditor.component'; + import { AppService } from 'src/app/app.service'; import { Organization } from '@models/organization.model'; @Component({ standalone: true, - imports: [CommonModule, FormsModule, IonicModule, IDEATranslationsModule], + imports: [CommonModule, FormsModule, IonicModule, IDEATranslationsModule, HTMLEditorComponent], selector: 'app-organization-card', template: ` @@ -19,17 +21,14 @@ import { Organization } from '@models/organization.model'; {{ organization.name }} - {{ organization.website }} + {{ organization.website }} {{ organization.contactEmail }} -
- -
- +
diff --git a/front-end/src/app/tabs/organizations/organizations.service.ts b/front-end/src/app/tabs/organizations/organizations.service.ts index 00d7707..19bd60c 100644 --- a/front-end/src/app/tabs/organizations/organizations.service.ts +++ b/front-end/src/app/tabs/organizations/organizations.service.ts @@ -74,7 +74,7 @@ export class OrganizationsService { */ async update(organization: Organization): Promise { return new Organization( - await this.api.putResource(['communications', organization.organizationId], { + await this.api.putResource(['organizations', organization.organizationId], { body: organization }) ); diff --git a/front-end/src/app/tabs/rooms/manageRooms.component.ts b/front-end/src/app/tabs/rooms/manageRooms.component.ts new file mode 100644 index 0000000..01b5a42 --- /dev/null +++ b/front-end/src/app/tabs/rooms/manageRooms.component.ts @@ -0,0 +1,197 @@ +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { Component, Input, OnInit } from '@angular/core'; +import { AlertController, IonicModule, ModalController } from '@ionic/angular'; +import { + IDEALoadingService, + IDEAMessageService, + IDEATranslationsModule, + IDEATranslationsService +} from '@idea-ionic/common'; + +import { HTMLEditorComponent } from 'src/app/common/htmlEditor.component'; + +import { VenuesService } from '../venues/venues.service'; +import { AppService } from '@app/app.service'; +import { MediaService } from 'src/app/common/media.service'; +import { RoomsService } from './rooms.service'; + +import { Room } from '@models/room.model'; +import { VenueLinked } from '@models/venue.model'; + +@Component({ + standalone: true, + imports: [CommonModule, FormsModule, IonicModule, IDEATranslationsModule, HTMLEditorComponent], + selector: 'app-manage-room', + template: ` + + + + + + + + {{ 'ROOMS.MANAGE_ROOM' | translate }} + + + + + + + + + + + + {{ 'ROOMS.NAME' | translate }} + + + + + {{ 'ROOMS.IMAGE_URL' | translate }} + + + + + + + + + {{ 'ROOMS.VENUE' | translate }} + + + + + {{ venue.name }} + + + + + + {{ 'ROOMS.INTERNAL_LOCATION' | translate }} + + + + + +

{{ 'ROOMS.DESCRIPTION' | translate }}

+
+
+ + + + {{ 'COMMON.DELETE' | translate }} + + +
+
+ ` +}) +export class ManageRoomComponent implements OnInit { + /** + * The room to manage. + */ + @Input() room: Room; + + entityBeforeChange: Room; + venues: VenueLinked[] = []; + + errors = new Set(); + + constructor( + private modalCtrl: ModalController, + private alertCtrl: AlertController, + private t: IDEATranslationsService, + private loading: IDEALoadingService, + private message: IDEAMessageService, + private _media: MediaService, + private _venues: VenuesService, + private _rooms: RoomsService, + public app: AppService + ) {} + + async ngOnInit() { + this.entityBeforeChange = new Room(this.room); + this.venues = (await this._venues.getList({ force: true })).map(v => new VenueLinked(v)); + } + + hasFieldAnError(field: string): boolean { + return this.errors.has(field); + } + + browseImagesForElementId(elementId: string): void { + document.getElementById(elementId).click(); + } + 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.room.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.room.validate()); + if (this.errors.size) return this.message.error('COMMON.FORM_HAS_ERROR_TO_CHECK'); + + try { + await this.loading.show(); + let result: Room; + if (!this.room.roomId) result = await this._rooms.insert(this.room); + else result = await this._rooms.update(this.room); + this.room.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.room = this.entityBeforeChange; + this.modalCtrl.dismiss(); + } + + async askAndDelete(): Promise { + const doDelete = async (): Promise => { + try { + await this.loading.show(); + await this._rooms.delete(this.room); + this.message.success('COMMON.OPERATION_COMPLETED'); + this.close(); + this.app.goToInTabs(['venues', this.room.venue.venueId]); + } 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.alertCtrl.create({ header, message, 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/rooms/room.page.html b/front-end/src/app/tabs/rooms/room.page.html index c629c84..c71a571 100644 --- a/front-end/src/app/tabs/rooms/room.page.html +++ b/front-end/src/app/tabs/rooms/room.page.html @@ -1,6 +1,18 @@ + + + + + {{ 'ROOMS.DETAILS' | translate }} + + + + + + + diff --git a/front-end/src/app/tabs/rooms/room.page.ts b/front-end/src/app/tabs/rooms/room.page.ts index d8abf7f..d778a98 100644 --- a/front-end/src/app/tabs/rooms/room.page.ts +++ b/front-end/src/app/tabs/rooms/room.page.ts @@ -1,8 +1,11 @@ import { Component, OnInit } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; +import { ModalController } from '@ionic/angular'; import { IDEALoadingService, IDEAMessageService } from '@idea-ionic/common'; +import { ManageRoomComponent } from './manageRooms.component'; + import { AppService } from 'src/app/app.service'; import { RoomsService } from './rooms.service'; import { SessionsService } from '../sessions/sessions.service'; @@ -21,6 +24,7 @@ export class RoomPage implements OnInit { constructor( private route: ActivatedRoute, + private modalCtrl: ModalController, private loading: IDEALoadingService, private message: IDEAMessageService, private _sessions: SessionsService, @@ -37,7 +41,7 @@ export class RoomPage implements OnInit { await this.loading.show(); const roomId = this.route.snapshot.paramMap.get('roomId'); this.room = await this._rooms.getById(roomId); - this.sessions = await this._sessions.getList({ room: this.room.roomId, force: true }); + this.sessions = await this._sessions.getSessionsInARoom(this.room.roomId); } catch (err) { this.message.error('COMMON.NOT_FOUND'); } finally { @@ -46,6 +50,20 @@ export class RoomPage implements OnInit { } async filterSessions(search: string = ''): Promise { - this.sessions = await this._sessions.getList({ search, room: this.room.roomId }); + this.sessions = await this._sessions.getSessionsInARoom(search, this.room.roomId) + } + + async manageRoom(room: Room): Promise { + if (!this.app.user.permissions.canManageContents) return + + const modal = await this.modalCtrl.create({ + component: ManageRoomComponent, + componentProps: { room }, + backdropDismiss: false + }); + modal.onDidDismiss().then(async (): Promise => { + this.room = await this._rooms.getById(room.roomId); + }); + await modal.present(); } } diff --git a/front-end/src/app/tabs/rooms/roomCard.component.ts b/front-end/src/app/tabs/rooms/roomCard.component.ts index bc3077a..724dbc7 100644 --- a/front-end/src/app/tabs/rooms/roomCard.component.ts +++ b/front-end/src/app/tabs/rooms/roomCard.component.ts @@ -8,10 +8,11 @@ import { IDEATranslationsModule } from '@idea-ionic/common'; import { AppService } from 'src/app/app.service'; import { Room } from '@models/room.model'; +import { HTMLEditorComponent } from 'src/app/common/htmlEditor.component'; @Component({ standalone: true, - imports: [CommonModule, FormsModule, IonicModule, IDEATranslationsModule], + imports: [CommonModule, FormsModule, IonicModule, IDEATranslationsModule, HTMLEditorComponent], selector: 'app-room-card', template: ` @@ -37,9 +38,7 @@ import { Room } from '@models/room.model'; {{ room.internalLocation }} -
- -
+
diff --git a/front-end/src/app/tabs/rooms/rooms.service.ts b/front-end/src/app/tabs/rooms/rooms.service.ts index 6112bda..87f1750 100644 --- a/front-end/src/app/tabs/rooms/rooms.service.ts +++ b/front-end/src/app/tabs/rooms/rooms.service.ts @@ -15,7 +15,9 @@ export class RoomsService { constructor(private api: IDEAApiService) {} private async loadList(venue?: string): Promise { - this.rooms = (await this.api.getResource(['rooms'], { params: { venue } })).map(r => new Room(r)); + const params: any = {} + if (venue) params.venue = venue + this.rooms = (await this.api.getResource(['rooms'], { params })).map(r => new Room(r)); } /** diff --git a/front-end/src/app/tabs/sessions/manageSession.component.ts b/front-end/src/app/tabs/sessions/manageSession.component.ts new file mode 100644 index 0000000..e7fdeb5 --- /dev/null +++ b/front-end/src/app/tabs/sessions/manageSession.component.ts @@ -0,0 +1,242 @@ +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { Component, Input, OnInit } from '@angular/core'; +import { AlertController, IonicModule, ModalController } from '@ionic/angular'; +import { + IDEALoadingService, + IDEAMessageService, + IDEATranslationsModule, + IDEATranslationsService +} from '@idea-ionic/common'; + +import { HTMLEditorComponent } from 'src/app/common/htmlEditor.component'; + +import { AppService } from '@app/app.service'; +import { SpeakersService } from '../speakers/speakers.service'; +import { RoomsService } from '../rooms/rooms.service'; +import { SessionsService } from './sessions.service'; + +import { SpeakerLinked } from '@models/speaker.model'; +import { Session, SessionType } from '@models/session.model'; +import { RoomLinked } from '@models/room.model'; + +@Component({ + standalone: true, + imports: [CommonModule, FormsModule, IonicModule, IDEATranslationsModule, HTMLEditorComponent], + selector: 'app-manage-session', + template: ` + + + + + + + + {{ 'SESSIONS.MANAGE_SESSION' | translate }} + + + + + + + + + + + + {{ 'SESSIONS.CODE' | translate }} + + + + + + {{ 'SESSIONS.NAME' | translate }} + + + + + + {{ 'SESSIONS.TYPE' | translate }} + + + + + {{ 'SESSIONS.TYPES.' + type.key | translate }} + + + + + + {{ 'SESSIONS.STARTS_AT' | translate }} + + + + + + + {{ 'SESSIONS.DURATION_MINUTES' | translate }} + + + + + + {{ 'SESSIONS.PARTICIPANT_LIMIT' | translate }} + + + + + +

+ {{ 'SESSIONS.SPEAKERS' | translate }} + +

+
+
+ @if (session.speakers?.length) { + + + {{ speaker.name }} + + + + + } @else { + + + {{ 'SESSIONS.NO_SPEAKERS' | translate }} + + } + + {{ 'SESSIONS.ADD_SPEAKER' | translate }} + + + {{ speaker.name }} + + + + + +

+ {{ 'SESSIONS.ROOM' | translate }} + +

+
+
+ + {{ 'SESSIONS.ROOM' | translate }} + + + {{ room.name }} + + + + + +

{{ 'SESSIONS.DESCRIPTION' | translate }}

+
+
+ + + + {{ 'COMMON.DELETE' | translate }} + + +
+
+ ` +}) +export class ManageSessionComponent implements OnInit { + /** + * The session to manage. + */ + @Input() session: Session; + entityBeforeChange: Session; + + types = SessionType; + speakers: SpeakerLinked[] = []; + rooms: RoomLinked[] = []; + + errors = new Set(); + + constructor( + private modalCtrl: ModalController, + private alertCtrl: AlertController, + private t: IDEATranslationsService, + private loading: IDEALoadingService, + private message: IDEAMessageService, + private _rooms: RoomsService, + private _speakers: SpeakersService, + private _sessions: SessionsService, + public app: AppService + ) {} + + async ngOnInit() { + this.entityBeforeChange = new Session(this.session); + this.rooms = (await this._rooms.getList({ force: true })).map(r => new RoomLinked(r)); + this.speakers = (await this._speakers.getList({ force: true })).map(s => new SpeakerLinked(s)); + } + + hasFieldAnError(field: string): boolean { + return this.errors.has(field); + } + + addSpeaker(ev: any): void { + const speaker = ev?.detail?.value; + if (!speaker) return; + + if (this.session.speakers.some(s => s.speakerId === speaker.speakerId)) return; + + this.session.speakers.push(speaker); + } + + removeSpeaker(speaker: SpeakerLinked): void { + this.session.speakers = this.session.speakers.filter(s => s.speakerId !== speaker.speakerId); + } + + async save(): Promise { + this.session = new Session(this.session); + this.errors = new Set(this.session.validate()); + if (this.errors.size) return this.message.error('COMMON.FORM_HAS_ERROR_TO_CHECK'); + + try { + await this.loading.show(); + let result: Session; + if (!this.session.sessionId) result = await this._sessions.insert(this.session); + else result = await this._sessions.update(this.session); + this.session.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.session = this.entityBeforeChange; + this.modalCtrl.dismiss(); + } + + async askAndDelete(): Promise { + const doDelete = async (): Promise => { + try { + await this.loading.show(); + await this._sessions.delete(this.session); + this.message.success('COMMON.OPERATION_COMPLETED'); + this.close(); + } 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.alertCtrl.create({ header, message, buttons }); + alert.present(); + } +} diff --git a/front-end/src/app/tabs/sessions/session.page.html b/front-end/src/app/tabs/sessions/session.page.html new file mode 100644 index 0000000..a53275e --- /dev/null +++ b/front-end/src/app/tabs/sessions/session.page.html @@ -0,0 +1,25 @@ + +
+ + + + + + + + + + + + + + +
+
\ No newline at end of file diff --git a/front-end/src/app/tabs/sessions/session.page.scss b/front-end/src/app/tabs/sessions/session.page.scss new file mode 100644 index 0000000..e69de29 diff --git a/front-end/src/app/tabs/sessions/session.page.ts b/front-end/src/app/tabs/sessions/session.page.ts new file mode 100644 index 0000000..a99398c --- /dev/null +++ b/front-end/src/app/tabs/sessions/session.page.ts @@ -0,0 +1,133 @@ +import { Component, OnInit } from '@angular/core'; +import { ModalController } from '@ionic/angular'; + +import { AppService } from 'src/app/app.service'; +import { IDEALoadingService, IDEAMessageService, IDEATranslationsService } from '@idea-ionic/common'; + +import { ManageSessionComponent } from './manageSession.component'; + +import { SessionsService } from './sessions.service'; + +import { Session } from '@models/session.model'; +import { ActivatedRoute } from '@angular/router'; + +@Component({ + selector: 'app-session', + templateUrl: './session.page.html', + styleUrls: ['./session.page.scss'] +}) +export class SessionPage implements OnInit { + + session: Session; + favoriteSessionsIds: string[] = []; + registeredSessionsIds: string[] = []; + selectedSession: Session; + + constructor( + private route: ActivatedRoute, + private modalCtrl: ModalController, + private loading: IDEALoadingService, + private message: IDEAMessageService, + public _sessions: SessionsService, + public t: IDEATranslationsService, + public app: AppService + ) {} + + ngOnInit() { + this.loadData(); + } + + async loadData() { + try { + await this.loading.show(); + const sessionId = this.route.snapshot.paramMap.get('sessionId'); + this.session = await this._sessions.getById(sessionId); + // WARNING: do not pass any segment in order to get the favorites on the next api call. + // @todo improvable. Just amke a call to see if a session is or isn't favorited/registerd using a getById + const favoriteSessions = await this._sessions.getList({ force: true }); + this.favoriteSessionsIds = favoriteSessions.map( s => s.sessionId); + this.registeredSessionsIds = (await this._sessions.loadUserRegisteredSessions()).map(ur => ur.sessionId); + } catch (error) { + this.message.error('COMMON.OPERATION_FAILED'); + } finally { + this.loading.hide(); + } + } + + isSessionInFavorites(session: Session): boolean { + return this.favoriteSessionsIds.includes(session.sessionId); + } + + async toggleFavorite(ev: any, session: Session): Promise { + ev?.stopPropagation() + try { + await this.loading.show(); + if (this.isSessionInFavorites(session)) { + await this._sessions.removeFromFavorites(session.sessionId); + this.favoriteSessionsIds = this.favoriteSessionsIds.filter(id => id !== session.sessionId); + } else { + await this._sessions.addToFavorites(session.sessionId); + this.favoriteSessionsIds.push(session.sessionId); + }; + } catch (error) { + this.message.error('COMMON.OPERATION_FAILED'); + } finally { + this.loading.hide(); + } + } + + isUserRegisteredInSession(session: Session): boolean { + return this.registeredSessionsIds.includes(session.sessionId); + } + + async toggleRegister(ev: any, session: Session): Promise { + ev?.stopPropagation() + try { + await this.loading.show(); + if (this.isUserRegisteredInSession(session)) { + await this._sessions.unregisterFromSession(session.sessionId); + this.favoriteSessionsIds = this.favoriteSessionsIds.filter(id => id !== session.sessionId); + this.registeredSessionsIds = this.registeredSessionsIds.filter(id => id !== session.sessionId); + } else { + 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!"){ + this.message.error('SESSIONS.CANT_SIGN_UP'); + } else if (error.message === 'Registrations are closed!'){ + this.message.error('SESSIONS.REGISTRATION_CLOSED'); + } 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.'){ + this.message.error('SESSIONS.OVERLAP'); + } else this.message.error('COMMON.OPERATION_FAILED'); + } finally { + this.loading.hide(); + } + } + + + async manageSession(): Promise { + if (!this.session) return; + + if (!this.app.user.permissions.canManageContents) return + + const modal = await this.modalCtrl.create({ + component: ManageSessionComponent, + componentProps: { session: this.session }, + backdropDismiss: false + }); + modal.onDidDismiss().then(async (): Promise => { + try { + this.session = await this._sessions.getById(this.session.sessionId); + } catch (error) { + // deleted + this.session = null; + } + }); + await modal.present(); + } +} \ No newline at end of file diff --git a/front-end/src/app/tabs/sessions/sessionCard.component.ts b/front-end/src/app/tabs/sessions/sessionCard.component.ts index 75c56de..97074ec 100644 --- a/front-end/src/app/tabs/sessions/sessionCard.component.ts +++ b/front-end/src/app/tabs/sessions/sessionCard.component.ts @@ -5,21 +5,39 @@ import { IonicModule } from '@ionic/angular'; import { IDEATranslationsModule } from '@idea-ionic/common'; +import { HTMLEditorComponent } from 'src/app/common/htmlEditor.component'; + import { AppService } from 'src/app/app.service'; import { Session } from '@models/session.model'; @Component({ standalone: true, - imports: [CommonModule, FormsModule, IonicModule, IDEATranslationsModule], + imports: [CommonModule, FormsModule, IonicModule, IDEATranslationsModule, HTMLEditorComponent], selector: 'app-session-card', template: ` - + {{ session.name }} - {{ session.description }} + + + + + {{ app.formatTime(session.startsAt) }} - {{ app.formatTime(session.endsAt) }} +

({{ app.formatDateShort(session.startsAt) }})

+
+ + {{ session.code }} + +
+
@@ -36,9 +54,7 @@ import { Session } from '@models/session.model'; {{ session.description }} -
- -
+
diff --git a/front-end/src/app/tabs/sessions/sessionDetail.component.html b/front-end/src/app/tabs/sessions/sessionDetail.component.html new file mode 100644 index 0000000..1116a88 --- /dev/null +++ b/front-end/src/app/tabs/sessions/sessionDetail.component.html @@ -0,0 +1,85 @@ + + + + + + + + {{ session.code }} + + + + + {{ session.name }} + + + + + + + + {{ 'SESSIONS.TYPES.' + session.type | translate }} + + + + {{ app.formatDateShort(session.startsAt) }} + + + + {{ app.formatTime(session.startsAt) }} - {{ app.formatTime(session.endsAt) }} + + + + {{ session.durationMinutes }} {{ 'COMMON.MINUTES' | translate }} + + + + {{ session.room.name }} ({{ session.room.venue.name }}) + + + + + + {{ speaker.name }} + + + + + + + {{ session.isFull() ? this.t._('COMMON.FULL') : session.numberOfParticipants + '/' + session.limitOfParticipants }} + + + + + + + + + + + + + + + + + diff --git a/front-end/src/app/tabs/sessions/sessionDetail.component.scss b/front-end/src/app/tabs/sessions/sessionDetail.component.scss new file mode 100644 index 0000000..3367d6e --- /dev/null +++ b/front-end/src/app/tabs/sessions/sessionDetail.component.scss @@ -0,0 +1,3 @@ +ion-content { + min-height: 100vh; +} diff --git a/front-end/src/app/tabs/sessions/sessionDetail.component.ts b/front-end/src/app/tabs/sessions/sessionDetail.component.ts new file mode 100644 index 0000000..2dbd5c3 --- /dev/null +++ b/front-end/src/app/tabs/sessions/sessionDetail.component.ts @@ -0,0 +1,22 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; + +import { AppService } from '@app/app.service'; +import { IDEATranslationsService } from '@idea-ionic/common'; + +import { Session } from '@models/session.model'; +import { SessionsService } from './sessions.service'; + +@Component({ + selector: 'app-session-detail', + templateUrl: 'sessionDetail.component.html', + styleUrls: ['sessionDetail.component.scss'] +}) +export class SessionDetailComponent { + @Input() session: Session; + @Input() isSessionInFavorites: boolean; + @Input() isUserRegisteredInSession: boolean; + @Output() favorite = new EventEmitter(); + @Output() register = new EventEmitter(); + + constructor(public _sessions: SessionsService, public t: IDEATranslationsService, public app: AppService) {} +} diff --git a/front-end/src/app/tabs/sessions/sessionItem.component.ts b/front-end/src/app/tabs/sessions/sessionItem.component.ts new file mode 100644 index 0000000..570d19c --- /dev/null +++ b/front-end/src/app/tabs/sessions/sessionItem.component.ts @@ -0,0 +1 @@ +// @todo refactor items in sessions page to a component here \ No newline at end of file diff --git a/front-end/src/app/tabs/sessions/sessions-routing.module.ts b/front-end/src/app/tabs/sessions/sessions-routing.module.ts new file mode 100644 index 0000000..0aa0f61 --- /dev/null +++ b/front-end/src/app/tabs/sessions/sessions-routing.module.ts @@ -0,0 +1,16 @@ +import { NgModule } from '@angular/core'; +import { Routes, RouterModule } from '@angular/router'; + +import { SessionsPage } from './sessions.page'; +import { SessionPage } from './session.page'; + +const routes: Routes = [ + { path: '', component: SessionsPage }, + { path: ':sessionId', component: SessionPage } +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) +export class SessionsRoutingModule {} diff --git a/front-end/src/app/tabs/sessions/sessions.module.ts b/front-end/src/app/tabs/sessions/sessions.module.ts new file mode 100644 index 0000000..d6925b3 --- /dev/null +++ b/front-end/src/app/tabs/sessions/sessions.module.ts @@ -0,0 +1,24 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { IonicModule } from '@ionic/angular'; +import { FormsModule } from '@angular/forms'; +import { IDEATranslationsModule } from '@idea-ionic/common'; + +import { SessionsPage } from './sessions.page'; +import { SessionPage } from './session.page'; +import { SessionsRoutingModule } from './sessions-routing.module'; +import { SessionDetailComponent } from './sessionDetail.component'; +import { HTMLEditorComponent } from 'src/app/common/htmlEditor.component'; + +@NgModule({ + imports: [ + CommonModule, + FormsModule, + IonicModule, + IDEATranslationsModule, + SessionsRoutingModule, + HTMLEditorComponent + ], + declarations: [SessionsPage, SessionPage, SessionDetailComponent] +}) +export class SessionsModule {} diff --git a/front-end/src/app/tabs/sessions/sessions.page.html b/front-end/src/app/tabs/sessions/sessions.page.html new file mode 100644 index 0000000..4578ca6 --- /dev/null +++ b/front-end/src/app/tabs/sessions/sessions.page.html @@ -0,0 +1,115 @@ + + + + {{ 'SESSIONS.LIST' | translate }} + + + + + + + + + + + + + + {{ app.formatDateShort(day) }} + + + + + + + {{ 'COMMON.NO_ELEMENT_FOUND' | translate }} + + + + + + + {{ app.formatTime(session.startsAt) }} + {{ app.formatTime(session.endsAt) }} + + + {{ session.name }} +

+ + {{ 'SESSIONS.TYPES.' + session.type | translate }} + +

+

+ + + {{ session.room.name }} ({{ session.room.venue.name }}) + +
+ + + {{ session.getSpeakers() }} + +

+
+ + + @if(session.requiresRegistration) { + {{ session.isFull() ? this.t._('COMMON.FULL') : session.numberOfParticipants + '/' + session.limitOfParticipants }} + } @else { + {{ 'COMMON.OPEN' | translate }} + } + + + + + + + + + + +
+
+
+ + + + + + + + + + + +
+
+
\ No newline at end of file diff --git a/front-end/src/app/tabs/sessions/sessions.page.scss b/front-end/src/app/tabs/sessions/sessions.page.scss new file mode 100644 index 0000000..59f4fc2 --- /dev/null +++ b/front-end/src/app/tabs/sessions/sessions.page.scss @@ -0,0 +1,62 @@ +ion-list { + overflow-y: auto; + max-height: 100vh; +} + +ion-item { + box-shadow: '0 0 5px 3px rgba(0, 0, 0, 0.05)'; + border-radius: 12px; + margin: 8; +} + +.registeredSession.favoritedSession { + border-color: var(--ion-color-primary); + border-style: solid ; +} + +.favoritedSession { + border-color: var(--ion-color-warning); + border-style: solid; +} + +ion-label { + margin-top: 0px; + padding-top: 0px; +} + +.sessionTitle { + font-weight: 600; +} + +.borderRight { + border-right-style: solid; +} + +ion-note { + margin-top: auto; + margin-bottom: auto; + + b, span { + display: block; + padding: 5px; + margin: 0px + } +} + +ion-badge { + margin-top: 2; + font-size: '0.8em'; +} + +p { + ion-icon { + vertical-align: middle; + } +} + +ion-searchbar { + margin-left: 0; + margin-right: 0; + padding-left: 0; + padding-right: 0; +} diff --git a/front-end/src/app/tabs/sessions/sessions.page.ts b/front-end/src/app/tabs/sessions/sessions.page.ts new file mode 100644 index 0000000..c091764 --- /dev/null +++ b/front-end/src/app/tabs/sessions/sessions.page.ts @@ -0,0 +1,155 @@ +import { Component, ViewChild } from '@angular/core'; +import { IonContent, IonSearchbar, ModalController } from '@ionic/angular'; + +import { AppService } from 'src/app/app.service'; +import { IDEALoadingService, IDEAMessageService, IDEATranslationsService } from '@idea-ionic/common'; + +import { ManageSessionComponent } from './manageSession.component'; + +import { SessionsService } from './sessions.service'; + +import { Session } from '@models/session.model'; + +@Component({ + selector: 'app-sessions', + templateUrl: './sessions.page.html', + styleUrls: ['./sessions.page.scss'] +}) +export class SessionsPage { + @ViewChild(IonContent) content: IonContent; + @ViewChild(IonContent) searchbar: IonSearchbar; + + + days: string[] + sessions: Session[]; + favoriteSessionsIds: string[] = []; + registeredSessionsIds: string[] = []; + selectedSession: Session; + + segment = '' + + constructor( + private modalCtrl: ModalController, + private loading: IDEALoadingService, + private message: IDEAMessageService, + public _sessions: SessionsService, + public t: IDEATranslationsService, + public app: AppService + ) {} + + async ionViewDidEnter() { + await this.loadData(); + } + + async loadData() { + 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.sessions = await this._sessions.getList({ force: true }); + this.favoriteSessionsIds = this.sessions.map( s => s.sessionId); + this.registeredSessionsIds = (await this._sessions.loadUserRegisteredSessions()).map(ur => ur.sessionId); + this.days = await this._sessions.getSessionDays() + } catch (error) { + this.message.error('COMMON.OPERATION_FAILED'); + } finally { + this.loading.hide(); + } + } + 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 }); + } + + isSessionInFavorites(session: Session): boolean { + return this.favoriteSessionsIds.includes(session.sessionId); + } + + async toggleFavorite(ev: any, session: Session): Promise { + ev?.stopPropagation() + try { + await this.loading.show(); + if (this.isSessionInFavorites(session)) { + await this._sessions.removeFromFavorites(session.sessionId); + this.favoriteSessionsIds = this.favoriteSessionsIds.filter(id => id !== session.sessionId); + if (!this.segment) this.sessions = this.sessions.filter(s => s.sessionId !== session.sessionId); + } else { + await this._sessions.addToFavorites(session.sessionId); + this.favoriteSessionsIds.push(session.sessionId); + }; + } catch (error) { + this.message.error('COMMON.OPERATION_FAILED'); + } finally { + this.loading.hide(); + } + } + + isUserRegisteredInSession(session: Session): boolean { + return this.registeredSessionsIds.includes(session.sessionId); + } + + async toggleRegister(ev: any, session: Session): Promise { + ev?.stopPropagation() + try { + await this.loading.show(); + if (this.isUserRegisteredInSession(session)) { + await this._sessions.unregisterFromSession(session.sessionId); + this.favoriteSessionsIds = this.favoriteSessionsIds.filter(id => id !== session.sessionId); + this.registeredSessionsIds = this.registeredSessionsIds.filter(id => id !== session.sessionId); + if (!this.segment) this.sessions = this.sessions.filter(s => s.sessionId !== session.sessionId); + } else { + 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!"){ + this.message.error('SESSIONS.CANT_SIGN_UP'); + } else if (error.message === 'Registrations are closed!'){ + this.message.error('SESSIONS.REGISTRATION_CLOSED'); + } 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.'){ + this.message.error('SESSIONS.OVERLAP'); + } else this.message.error('COMMON.OPERATION_FAILED'); + } finally { + this.loading.hide(); + } + } + + openDetail(ev: any, session: Session): void { + 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 + + const modal = await this.modalCtrl.create({ + component: ManageSessionComponent, + componentProps: { session: this.selectedSession }, + backdropDismiss: false + }); + modal.onDidDismiss().then(async (): Promise => { + try { + this.selectedSession = await this._sessions.getById(this.selectedSession.sessionId); + } catch (error) { + // deleted + this.selectedSession = null; + this.sessions = await this._sessions.getList({ force: true }) + } + }); + await modal.present(); + } +} \ No newline at end of file diff --git a/front-end/src/app/tabs/sessions/sessions.service.ts b/front-end/src/app/tabs/sessions/sessions.service.ts index 2fdd5fd..c4b2a4f 100644 --- a/front-end/src/app/tabs/sessions/sessions.service.ts +++ b/front-end/src/app/tabs/sessions/sessions.service.ts @@ -1,11 +1,15 @@ import { Injectable } from '@angular/core'; import { IDEAApiService } from '@idea-ionic/common'; -import { Session } from '@models/session.model'; +import { Session, SessionType } from '@models/session.model'; +import { SessionRegistration } from '@models/sessionRegistration.model'; @Injectable({ providedIn: 'root' }) export class SessionsService { private sessions: Session[]; + // It's the IDs only + private userFavoriteSessions: string[]; + private userRegisteredSessions: SessionRegistration[]; /** * The number of sessions to consider for the pagination, when active. @@ -14,11 +18,29 @@ export class SessionsService { constructor(private api: IDEAApiService) {} - private async loadList(speaker?: string, room?: string): Promise { - const params: any = {}; - if (speaker) params.speaker = speaker; - if (room) params.room = room; - this.sessions = (await this.api.getResource(['sessions'], { params: params })).map(s => new Session(s)); + private async loadList(): Promise { + this.sessions = (await this.api.getResource(['sessions'])).map(s => new Session(s)); + } + + private async loadUserFavoriteSessions(): Promise { + const body: any = { action: 'GET_FAVORITE_SESSIONS' }; + this.userFavoriteSessions = await this.api.patchResource(['users', 'me'], { body }); + } + + async loadUserRegisteredSessions(): Promise { + this.userRegisteredSessions = await this.api.getResource(['registrations']); + return this.userRegisteredSessions + } + + async getSpeakerSessions(speaker: string, search?: string): Promise { + const sessions: Session[] = (await this.api.getResource(['sessions'], { params: { speaker } })).map( + s => new Session(s) + ); + return this.applySearchToSessions(sessions, search); + } + async getSessionsInARoom(room: string, search?: string): Promise { + let sessions: Session[] = (await this.api.getResource(['sessions'], { params: { room } })).map(s => new Session(s)); + return this.applySearchToSessions(sessions, search); } /** @@ -33,24 +55,23 @@ export class SessionsService { withPagination?: boolean; startPaginationAfterId?: string; search?: string; - speaker?: string; - room?: string; - }): Promise { - if (!this.sessions || options.force) await this.loadList(options.speaker, options.room); + segment?: string, + } = {}): Promise { + if (!this.sessions || options.force) await this.loadList(); if (!this.sessions) return null; options.search = options.search ? String(options.search).toLowerCase() : ''; let filteredList = this.sessions.slice(); - if (options.search) - filteredList = filteredList.filter(x => - options.search - .split(' ') - .every(searchTerm => - [x.sessionId, x.code, x.name].filter(f => f).some(f => f.toLowerCase().includes(searchTerm)) - ) - ); + if (options.search) filteredList = this.applySearchToSessions(filteredList, options.search) + + // @todo should we hide past sessions? or disable them? + if (!options.segment) { + await this.loadUserFavoriteSessions(); + filteredList = filteredList.filter(s => this.userFavoriteSessions.includes(s.sessionId)) || []; + } + else filteredList = filteredList.filter(s => s.startsAt.startsWith(options.segment)) || []; if (options.withPagination && filteredList.length > this.MAX_PAGE_SIZE) { let indexOfLastOfPreviousPage = 0; @@ -62,6 +83,25 @@ export class SessionsService { return filteredList; } + private applySearchToSessions(sessions: Session[], search: string) { + if (search) + sessions = sessions.filter(x => + search + .split(' ') + .every(searchTerm => + [x.sessionId, x.code, x.name, x.getSpeakers()].filter(f => f).some(f => f.toLowerCase().includes(searchTerm)) + ) + ); + + return sessions + } + + async getSessionDays(): Promise { + if (!this.sessions) await this.loadList(); + + return Array.from(new Set(this.sessions.map(s => s.startsAt.slice(0, 10)))).sort() + } + /** * Get the full details of a session by its id. */ @@ -91,4 +131,38 @@ export class SessionsService { async delete(session: Session): Promise { await this.api.deleteResource(['sessions', session.sessionId]); } + + async addToFavorites(sessionId: string){ + const body: any = { action: 'ADD_FAVORITE_SESSION', sessionId }; + this.userFavoriteSessions = await this.api.patchResource(['users', 'me'], { body }); + } + async removeFromFavorites(sessionId: string){ + const body: any = { action: 'REMOVE_FAVORITE_SESSION', sessionId }; + this.userFavoriteSessions = await this.api.patchResource(['users', 'me'], { body }); + } + async registerInSession(sessionId: string){ + this.userFavoriteSessions = await this.api.postResource(['registrations', sessionId]); + } + async unregisterFromSession(sessionId: string){ + this.userFavoriteSessions = await this.api.deleteResource(['registrations', sessionId]); + } + + getColourBySessionType(session: Session){ + switch(session.type) { + case SessionType.DISCUSSION: + return 'ESNcyan'; + case SessionType.TALK: + return 'ESNgreen'; + case SessionType.IGNITE: + return 'ESNpink'; + case SessionType.CAMPFIRE: + return 'ESNorange'; + case SessionType.INCUBATOR: + return 'ESNdarkBlue'; + case SessionType.HUB: + return 'dark'; + default: + return 'medium'; + } + } } diff --git a/front-end/src/app/tabs/speakers/manageSpeaker.component.ts b/front-end/src/app/tabs/speakers/manageSpeaker.component.ts new file mode 100644 index 0000000..1097e92 --- /dev/null +++ b/front-end/src/app/tabs/speakers/manageSpeaker.component.ts @@ -0,0 +1,224 @@ +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { Component, Input, OnInit } from '@angular/core'; +import { AlertController, IonicModule, ModalController } from '@ionic/angular'; +import { + IDEALoadingService, + IDEAMessageService, + IDEATranslationsModule, + IDEATranslationsService +} from '@idea-ionic/common'; + +import { HTMLEditorComponent } from 'src/app/common/htmlEditor.component'; + +import { AppService } from '@app/app.service'; +import { MediaService } from 'src/app/common/media.service'; +import { SpeakersService } from './speakers.service'; +import { OrganizationsService } from '../organizations/organizations.service'; + +import { OrganizationLinked } from '@models/organization.model'; +import { Speaker } from '@models/speaker.model'; + +@Component({ + standalone: true, + imports: [CommonModule, FormsModule, IonicModule, IDEATranslationsModule, HTMLEditorComponent], + selector: 'app-manage-speaker', + template: ` + + + + + + + + {{ 'SPEAKERS.MANAGE_SPEAKER' | translate }} + + + + + + + + + + + + {{ 'SPEAKERS.NAME' | translate }} + + + + + {{ 'SPEAKERS.IMAGE_URL' | translate }} + + + + + + + + + {{ 'SPEAKERS.ORGANIZATION' | translate }} + + + + + {{ organization.name }} + + + + + + {{ 'SPEAKERS.TITLE' | translate }} + + + + + + {{ 'SPEAKERS.EMAIL' | translate }} + + + + + +

{{ 'SPEAKERS.SOCIAL_MEDIA' | translate }}

+
+
+ + + + + + + + + + + + + + +

{{ 'SPEAKERS.DESCRIPTION' | translate }}

+
+
+ + + + {{ 'COMMON.DELETE' | translate }} + + +
+
+ ` +}) +export class ManageSpeakerComponent implements OnInit { + /** + * The speaker to manage. + */ + @Input() speaker: Speaker; + + entityBeforeChange: Speaker; + organizations: OrganizationLinked[] = []; + + errors = new Set(); + + constructor( + private modalCtrl: ModalController, + private alertCtrl: AlertController, + private t: IDEATranslationsService, + private loading: IDEALoadingService, + private message: IDEAMessageService, + private _media: MediaService, + private _organizations: OrganizationsService, + private _speakers: SpeakersService, + public app: AppService + ) {} + + async ngOnInit() { + this.entityBeforeChange = new Speaker(this.speaker); + this.organizations = (await this._organizations.getList({ force: true })).map(o => new OrganizationLinked(o)); + } + + hasFieldAnError(field: string): boolean { + return this.errors.has(field); + } + + browseImagesForElementId(elementId: string): void { + document.getElementById(elementId).click(); + } + 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.speaker.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.speaker.validate()); + if (this.errors.size) return this.message.error('COMMON.FORM_HAS_ERROR_TO_CHECK'); + + try { + await this.loading.show(); + let result: Speaker; + if (!this.speaker.speakerId) result = await this._speakers.insert(this.speaker); + else result = await this._speakers.update(this.speaker); + this.speaker.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.speaker = this.entityBeforeChange; + this.modalCtrl.dismiss(); + } + + async askAndDelete(): Promise { + const doDelete = async (): Promise => { + try { + await this.loading.show(); + await this._speakers.delete(this.speaker); + this.message.success('COMMON.OPERATION_COMPLETED'); + this.close(); + this.app.goToInTabs(['speakers']); + } 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.alertCtrl.create({ header, message, 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/speakers/speaker.page.html b/front-end/src/app/tabs/speakers/speaker.page.html index e7b589f..4c34562 100644 --- a/front-end/src/app/tabs/speakers/speaker.page.html +++ b/front-end/src/app/tabs/speakers/speaker.page.html @@ -1,6 +1,18 @@ + + + + + {{ 'SPEAKERS.DETAILS' | translate }} + + + + + + + @@ -27,7 +39,7 @@

{{ 'SPEAKERS.SESSIONS' | translate }}

- + diff --git a/front-end/src/app/tabs/speakers/speaker.page.ts b/front-end/src/app/tabs/speakers/speaker.page.ts index 3b4f34d..f5bd2c9 100644 --- a/front-end/src/app/tabs/speakers/speaker.page.ts +++ b/front-end/src/app/tabs/speakers/speaker.page.ts @@ -1,8 +1,11 @@ import { Component, OnInit } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; +import { ModalController } from '@ionic/angular'; import { IDEALoadingService, IDEAMessageService } from '@idea-ionic/common'; +import { ManageSpeakerComponent } from './manageSpeaker.component'; + import { AppService } from 'src/app/app.service'; import { SpeakersService } from './speakers.service'; import { SessionsService } from '../sessions/sessions.service'; @@ -21,6 +24,7 @@ export class SpeakerPage implements OnInit { constructor( private route: ActivatedRoute, + private modalCtrl: ModalController, private loading: IDEALoadingService, private message: IDEAMessageService, private _speakers: SpeakersService, @@ -37,7 +41,7 @@ export class SpeakerPage implements OnInit { await this.loading.show(); const speakerId = this.route.snapshot.paramMap.get('speakerId'); this.speaker = await this._speakers.getById(speakerId); - this.sessions = await this._sessions.getList({ speaker: this.speaker.speakerId, force: true }); + this.sessions = await this._sessions.getSpeakerSessions(this.speaker.speakerId) } catch (err) { this.message.error('COMMON.NOT_FOUND'); } finally { @@ -46,6 +50,20 @@ export class SpeakerPage implements OnInit { } async filterSessions(search: string = ''): Promise { - this.sessions = await this._sessions.getList({ search, speaker: this.speaker.speakerId }); + this.sessions = await this._sessions.getSpeakerSessions(this.speaker.speakerId, search); + } + + async manageSpeaker(speaker: Speaker): Promise { + if (!this.app.user.permissions.canManageContents) return + + const modal = await this.modalCtrl.create({ + component: ManageSpeakerComponent, + componentProps: { speaker }, + backdropDismiss: false + }); + modal.onDidDismiss().then(async (): Promise => { + this.speaker = await this._speakers.getById(speaker.speakerId); + }); + await modal.present(); } } diff --git a/front-end/src/app/tabs/speakers/speakerCard.component.ts b/front-end/src/app/tabs/speakers/speakerCard.component.ts index 68564d1..436ca8d 100644 --- a/front-end/src/app/tabs/speakers/speakerCard.component.ts +++ b/front-end/src/app/tabs/speakers/speakerCard.component.ts @@ -5,13 +5,15 @@ import { IonicModule } from '@ionic/angular'; import { IDEATranslationsModule } from '@idea-ionic/common'; +import { HTMLEditorComponent } from 'src/app/common/htmlEditor.component'; + import { AppService } from 'src/app/app.service'; import { Speaker } from '@models/speaker.model'; @Component({ standalone: true, - imports: [CommonModule, FormsModule, IonicModule, IDEATranslationsModule], + imports: [CommonModule, FormsModule, IonicModule, IDEATranslationsModule, HTMLEditorComponent], selector: 'app-speaker-card', template: ` @@ -29,7 +31,11 @@ import { Speaker } from '@models/speaker.model'; {{ speaker.name }} - + {{ speaker.organization.name }} @@ -38,9 +44,21 @@ import { Speaker } from '@models/speaker.model'; -
- -
+ + + + + + + + + + + + + + +
diff --git a/front-end/src/app/tabs/speakers/speakers.service.ts b/front-end/src/app/tabs/speakers/speakers.service.ts index 26c062b..3151d20 100644 --- a/front-end/src/app/tabs/speakers/speakers.service.ts +++ b/front-end/src/app/tabs/speakers/speakers.service.ts @@ -20,6 +20,13 @@ export class SpeakersService { this.speakers = (await this.api.getResource(['speakers'], { params: params })).map(s => new Speaker(s)); } + async getOrganizationSpeakers(organization: string, search?: string): Promise { + const speakers: Speaker[] = (await this.api.getResource(['speakers'], { params: { organization } })).map( + s => new Speaker(s) + ); + return this.applySearchToSpeakers(speakers, search); + } + /** * Get (and optionally filter) the list of speakers. * Note: it can be paginated. @@ -31,25 +38,15 @@ export class SpeakersService { withPagination?: boolean; startPaginationAfterId?: string; search?: string; - organization?: string; }): Promise { - if (!this.speakers || options.force) await this.loadList(options.organization); + if (!this.speakers || options.force) await this.loadList(); if (!this.speakers) return null; options.search = options.search ? String(options.search).toLowerCase() : ''; let filteredList = this.speakers.slice(); - if (options.search) - filteredList = filteredList.filter(x => - options.search - .split(' ') - .every(searchTerm => - [x.speakerId, x.name, x.contactEmail, x.organization?.organizationId, x.organization?.name] - .filter(f => f) - .some(f => f.toLowerCase().includes(searchTerm)) - ) - ); + if (options.search) filteredList = this.applySearchToSpeakers(filteredList, options.search) if (options.withPagination && filteredList.length > this.MAX_PAGE_SIZE) { let indexOfLastOfPreviousPage = 0; @@ -61,6 +58,21 @@ export class SpeakersService { return filteredList; } + private applySearchToSpeakers(speakers: Speaker[], search: string) { + if (search) + speakers = speakers.filter(x => + search + .split(' ') + .every(searchTerm => + [x.speakerId, x.name, x.contactEmail, x.organization?.organizationId, x.organization?.name] + .filter(f => f) + .some(f => f.toLowerCase().includes(searchTerm)) + ) + ); + + return speakers + } + /** * Get the full details of a speaker by its id. */ diff --git a/front-end/src/app/tabs/tabs.component.html b/front-end/src/app/tabs/tabs.component.html index d82ad15..457fb3c 100644 --- a/front-end/src/app/tabs/tabs.component.html +++ b/front-end/src/app/tabs/tabs.component.html @@ -3,10 +3,10 @@ + + {{ 'TABS.HOME' | translate }} + - - {{ 'TABS.HOME' | translate }} - {{ 'TABS.AGENDA' | translate }} @@ -28,10 +28,10 @@ + + + - - - diff --git a/front-end/src/app/tabs/tabs.routing.module.ts b/front-end/src/app/tabs/tabs.routing.module.ts index 4f76ec5..91e15d1 100644 --- a/front-end/src/app/tabs/tabs.routing.module.ts +++ b/front-end/src/app/tabs/tabs.routing.module.ts @@ -1,6 +1,9 @@ import { NgModule } from '@angular/core'; import { RouterModule, Routes } from '@angular/router'; +import { manageGuard } from '../manage.guard'; +import { spotGuard } from '../spot.guard'; + import { TabsComponent } from './tabs.component'; const routes: Routes = [ @@ -8,14 +11,15 @@ const routes: Routes = [ path: '', component: TabsComponent, children: [ - { path: '', redirectTo: 'user', pathMatch: 'full' }, + { path: '', redirectTo: 'home', pathMatch: 'full' }, { path: 'user', loadChildren: (): Promise => import('./user/user.module').then(m => m.UserModule) }, { path: 'manage', - loadChildren: (): Promise => import('./manage/manage.module').then(m => m.ManageModule) + loadChildren: (): Promise => import('./manage/manage.module').then(m => m.ManageModule), + canActivate: [manageGuard] }, { path: 'home', @@ -23,23 +27,33 @@ const routes: Routes = [ }, { path: 'menu', - loadChildren: (): Promise => import('./menu/menu.module').then(m => m.MenuModule) + loadChildren: (): Promise => import('./menu/menu.module').then(m => m.MenuModule), + canActivate: [spotGuard] }, { path: 'venues', - loadChildren: (): Promise => import('./venues/venues.module').then(m => m.VenuesModule) + loadChildren: (): Promise => import('./venues/venues.module').then(m => m.VenuesModule), + canActivate: [spotGuard] }, { path: 'rooms', - loadChildren: (): Promise => import('./rooms/rooms.module').then(m => m.RoomsModule) + loadChildren: (): Promise => import('./rooms/rooms.module').then(m => m.RoomsModule), + canActivate: [spotGuard] }, { path: 'organizations', - loadChildren: (): Promise => import('./organizations/organizations.module').then(m => m.OrganizationsModule) + loadChildren: (): Promise => import('./organizations/organizations.module').then(m => m.OrganizationsModule), + canActivate: [spotGuard] }, { path: 'speakers', - loadChildren: (): Promise => import('./speakers/speakers.module').then(m => m.SpeakersModule) + loadChildren: (): Promise => import('./speakers/speakers.module').then(m => m.SpeakersModule), + canActivate: [spotGuard] + }, + { + path: 'agenda', + loadChildren: (): Promise => import('./sessions/sessions.module').then(m => m.SessionsModule), + canActivate: [spotGuard] } ] } diff --git a/front-end/src/app/tabs/venues/manageVenue.component.ts b/front-end/src/app/tabs/venues/manageVenue.component.ts new file mode 100644 index 0000000..da3fb0a --- /dev/null +++ b/front-end/src/app/tabs/venues/manageVenue.component.ts @@ -0,0 +1,193 @@ +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { Component, Input, OnInit } from '@angular/core'; +import { AlertController, IonicModule, ModalController } from '@ionic/angular'; +import { + IDEALoadingService, + IDEAMessageService, + IDEATranslationsModule, + IDEATranslationsService +} from '@idea-ionic/common'; + +import { HTMLEditorComponent } from 'src/app/common/htmlEditor.component'; + +import { AppService } from '@app/app.service'; +import { MediaService } from 'src/app/common/media.service'; +import { VenuesService } from './venues.service'; + +import { Venue } from '@models/venue.model'; + +@Component({ + standalone: true, + imports: [CommonModule, FormsModule, IonicModule, IDEATranslationsModule, HTMLEditorComponent], + selector: 'app-manage-venue', + template: ` + + + + + + + + {{ 'VENUES.MANAGE_VENUE' | translate }} + + + + + + + + + + + + {{ 'VENUES.NAME' | translate }} + + + + + {{ 'VENUES.IMAGE_URL' | translate }} + + + + + + + + + {{ 'VENUES.ADDRESS' | translate }} + + + + + + {{ 'VENUES.LATITUDE' | translate }} + + + + + + {{ 'VENUES.LONGITUDE' | translate }} + + + + + +

{{ 'VENUES.DESCRIPTION' | translate }}

+
+
+ + + + {{ 'COMMON.DELETE' | translate }} + + +
+
+ ` +}) +export class ManageVenueComponent implements OnInit { + /** + * The venue to manage. + */ + @Input() venue: Venue; + + entityBeforeChange: Venue; + + errors = new Set(); + + constructor( + private modalCtrl: ModalController, + private alertCtrl: AlertController, + private t: IDEATranslationsService, + private loading: IDEALoadingService, + private message: IDEAMessageService, + private _media: MediaService, + private _venues: VenuesService, + public app: AppService + ) {} + + async ngOnInit() { + this.entityBeforeChange = new Venue(this.venue); + } + + hasFieldAnError(field: string): boolean { + return this.errors.has(field); + } + + browseImagesForElementId(elementId: string): void { + document.getElementById(elementId).click(); + } + 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.venue.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.venue.validate()); + if (this.errors.size) return this.message.error('COMMON.FORM_HAS_ERROR_TO_CHECK'); + + try { + await this.loading.show(); + let result: Venue; + if (!this.venue.venueId) result = await this._venues.insert(this.venue); + else result = await this._venues.update(this.venue); + this.venue.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.venue = this.entityBeforeChange; + this.modalCtrl.dismiss(); + } + + async askAndDelete(): Promise { + const doDelete = async (): Promise => { + try { + await this.loading.show(); + await this._venues.delete(this.venue); + this.message.success('COMMON.OPERATION_COMPLETED'); + this.close(); + this.app.goToInTabs(['venues']); + } 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.alertCtrl.create({ header, message, 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/venues/venue.page.html b/front-end/src/app/tabs/venues/venue.page.html index 77bc319..c0286a6 100644 --- a/front-end/src/app/tabs/venues/venue.page.html +++ b/front-end/src/app/tabs/venues/venue.page.html @@ -1,6 +1,18 @@ + + + + + {{ 'VENUES.DETAILS' | translate }} + + + + + + + diff --git a/front-end/src/app/tabs/venues/venue.page.ts b/front-end/src/app/tabs/venues/venue.page.ts index 9f9d2bf..183c9f2 100644 --- a/front-end/src/app/tabs/venues/venue.page.ts +++ b/front-end/src/app/tabs/venues/venue.page.ts @@ -1,8 +1,11 @@ import { Component, OnInit } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; +import { ModalController } from '@ionic/angular'; import { IDEALoadingService, IDEAMessageService } from '@idea-ionic/common'; +import { ManageVenueComponent } from './manageVenue.component'; + import { AppService } from 'src/app/app.service'; import { VenuesService } from './venues.service'; import { RoomsService } from '../rooms/rooms.service'; @@ -21,6 +24,7 @@ export class VenuePage implements OnInit { constructor( private route: ActivatedRoute, + private modalCtrl: ModalController, private loading: IDEALoadingService, private message: IDEAMessageService, private _venues: VenuesService, @@ -48,4 +52,18 @@ export class VenuePage implements OnInit { async filterRooms(search: string = ''): Promise { this.rooms = await this._rooms.getList({ search, venue: this.venue.venueId }); } + + async manageVenue(venue: Venue): Promise { + if (!this.app.user.permissions.canManageContents) return + + const modal = await this.modalCtrl.create({ + component: ManageVenueComponent, + componentProps: { venue }, + backdropDismiss: false + }); + modal.onDidDismiss().then(async (): Promise => { + this.venue = await this._venues.getById(venue.venueId); + }); + await modal.present(); + } } diff --git a/front-end/src/app/tabs/venues/venueCard.component.ts b/front-end/src/app/tabs/venues/venueCard.component.ts index 9ddc420..ce641d5 100644 --- a/front-end/src/app/tabs/venues/venueCard.component.ts +++ b/front-end/src/app/tabs/venues/venueCard.component.ts @@ -5,13 +5,15 @@ import { IonicModule } from '@ionic/angular'; import { IDEATranslationsModule } from '@idea-ionic/common'; +import { HTMLEditorComponent } from 'src/app/common/htmlEditor.component'; + import { AppService } from 'src/app/app.service'; import { Venue } from '@models/venue.model'; @Component({ standalone: true, - imports: [CommonModule, FormsModule, IonicModule, IDEATranslationsModule], + imports: [CommonModule, FormsModule, IonicModule, IDEATranslationsModule, HTMLEditorComponent], selector: 'app-venue-card', template: ` @@ -28,9 +30,7 @@ import { Venue } from '@models/venue.model'; --> -
- -
+
diff --git a/front-end/src/assets/i18n/en.json b/front-end/src/assets/i18n/en.json index da81a69..e470221 100644 --- a/front-end/src/assets/i18n/en.json +++ b/front-end/src/assets/i18n/en.json @@ -80,7 +80,9 @@ "DUPLICATE": "Duplicate", "ERROR": "Error", "OR": "Or", - "COPY": "Copy" + "COPY": "Copy", + "FULL": "Full", + "MINUTES": "Minutes" }, "AUTH": { "TITLE": "Erasmus Generation Meeting", @@ -287,10 +289,13 @@ "ORGANIZATIONS": "Organisations", "SESSIONS": "Sessions", "VENUES": "Venues", + "ROOMS": "Rooms", "REGISTRATIONS_OPTIONS": "Registrations options", "REGISTRATIONS_OPTIONS_I": "Open/close the registrations and other options.", "ALLOW_REGISTRATIONS": "Allow registrations", "ALLOW_EXTERNALS": "Allow externals to register", + "ALLOW_SESSION_REGISTRATIONS": "Allow session registrations", + "SESSION_BUFFER": "Time interval between sessions for registration.", "ALLOW_COUNTRY_LEADER_ASSIGN_SPOT": "Allow country leaders to assign spots", "REGISTRATION_FORM": "Registration form", "REGISTRATION_FORM_I": "Add, manage and remove the fields requested during the registration.", @@ -360,23 +365,78 @@ "PROCEED_TO_STRIPE": "Go to Stripe" }, "VENUES": { + "MANAGE_VENUE": "Manage venue", "LIST": "List", "MAP": "Map", "DETAILS": "Venue's details", - "ROOMS": "Venue's rooms" + "ROOMS": "Venue's rooms", + "NAME": "Name", + "IMAGE_URL": "Image URL", + "DESCRIPTION": "Description", + "ADDRESS": "Address", + "LATITUDE": "Latitude", + "LONGITUDE": "Longitude" }, "ROOMS": { + "MANAGE_ROOM": "Manage room", "DETAILS": "Room details", - "SESSIONS": "Sessions hosted in this room" + "SESSIONS": "Sessions hosted in this room", + "NAME": "Name", + "IMAGE_URL": "Image URL", + "DESCRIPTION": "Description", + "VENUE": "Venue", + "INTERNAL_LOCATION": "Internal location" }, "ORGANIZATIONS": { + "MANAGE_ORGANIZATION": "Manage organizations", "LIST": "Organizations list", "DETAILS": "Organization details", - "SPEAKERS": "Speakers from this organization" + "SPEAKERS": "Speakers from this organization", + "NAME": "Name", + "IMAGE_URL": "Image URL", + "WEBSITE": "Website", + "EMAIL": "Contact email", + "DESCRIPTION": "Description" }, "SPEAKERS": { + "MANAGE_SPEAKER": "Manage speaker", "LIST": "Speakers list", "DETAILS": "Speaker details", - "SESSIONS": "Sessions given by this speaker" + "SESSIONS": "Sessions given by this speaker", + "NAME": "Name", + "TITLE": "Title", + "ORGANIZATION": "Organization", + "IMAGE_URL": "Image URL", + "EMAIL": "Contact email", + "DESCRIPTION": "Description", + "SOCIAL_MEDIA": "Social media" + }, + "SESSIONS": { + "MANAGE_SESSION": "Manage session", + "LIST": "Session list", + "TYPES": { + "DISCUSSION": "Discussion", + "TALK": "EG Talk", + "IGNITE": "Ignite Session", + "CAMPFIRE": "Campfire", + "INCUBATOR": "Incubator", + "HUB": "Knowledge Hub", + "COMMON": "Common" + }, + "CANT_SIGN_UP": "User can't sign up for this session!", + "REGISTRATION_CLOSED": "Registrations are closed!", + "SESSION_FULL": "Session is full! Refresh your page.", + "OVERLAP": "You have 1 or more sessions during this time period.", + "CODE": "Session code", + "NAME": "Name", + "DESCRIPTION": "Description", + "TYPE": "Session type", + "STARTS_AT": "Session starts at", + "DURATION_MINUTES": "Session duration (minutes)", + "PARTICIPANT_LIMIT": "Participant limit", + "SPEAKERS": "Speakers", + "NO_SPEAKERS": "No speakers yet...", + "ADD_SPEAKER": "Add speaker", + "ROOM": "Room" } } diff --git a/scripts/migrate-data.sh b/scripts/migrate-data.sh new file mode 100755 index 0000000..14250cb --- /dev/null +++ b/scripts/migrate-data.sh @@ -0,0 +1,74 @@ +# This script serves the purpose of migrating the data from the dev tables to the prod tables. +# It is of the utmost importance that the data being passed is clean and consistent. + +C='\033[4;32m' # color +NC='\033[0m' # reset (no color) + +# set the script to exit in case of errors +set -o errexit + +# STEPS: + +# We need first to install a specific Python script: dynamodump. +# pip install dynamodump +# OR +# pip3 install dynamodump + +# Compile your parameters below: +DYNAMO_DUMP_LOCATION="/opt/homebrew/lib/python3.11/site-packages/dynamodump/dynamodump.py" +PROFILE="egm" +REGION="eu-central-1" +FROM="dev" +TO="prod" + +VENUES_TABLE="venues" +ROOMS_TABLE="rooms" +ORGANIZATIONS_TABLE="organizations" +SPEAKERS_TABLE="speakers" +SESSIONS_TABLE="sessions" + +# Backup data from your old account to your device +# The data is downloaded in the '~/dump' folder. + +echo -e "${C}Downloading venues table${NC}" +python3 ${DYNAMO_DUMP_LOCATION} --profile ${PROFILE} -r ${REGION} -m backup -s egm-${FROM}-api_${VENUES_TABLE} +echo -e "${C}DONE!${NC}" +echo -e "${C}Downloading rooms table${NC}" +python3 ${DYNAMO_DUMP_LOCATION} --profile ${PROFILE} -r ${REGION} -m backup -s egm-${FROM}-api_${ROOMS_TABLE} +echo -e "${C}DONE!${NC}" +echo -e "${C}Downloading organizations table${NC}" +python3 ${DYNAMO_DUMP_LOCATION} --profile ${PROFILE} -r ${REGION} -m backup -s egm-${FROM}-api_${ORGANIZATIONS_TABLE} +echo -e "${C}DONE!${NC}" +echo -e "${C}Downloading speakers table${NC}" +python3 ${DYNAMO_DUMP_LOCATION} --profile ${PROFILE} -r ${REGION} -m backup -s egm-${FROM}-api_${SPEAKERS_TABLE} +echo -e "${C}DONE!${NC}" +echo -e "${C}Downloading sessions table${NC}" +python3 ${DYNAMO_DUMP_LOCATION} --profile ${PROFILE} -r ${REGION} -m backup -s egm-${FROM}-api_${SESSIONS_TABLE} +echo -e "${C}DONE!${NC}" + + +# Restore data from your device to the tables + +echo -e "${C}Uploading venues table${NC}" +python3 ${DYNAMO_DUMP_LOCATION} --profile ${PROFILE} -r ${REGION} -m restore --skipThroughputUpdate -s egm-${FROM}-api_${VENUES_TABLE} -d egm-${TO}-api_${VENUES_TABLE} --dataOnly +echo -e "${C}DONE!${NC}" +echo -e "${C}Uploading rooms table${NC}" +python3 ${DYNAMO_DUMP_LOCATION} --profile ${PROFILE} -r ${REGION} -m restore --skipThroughputUpdate -s egm-${FROM}-api_${ROOMS_TABLE} -d egm-${TO}-api_${ROOMS_TABLE} --dataOnly +echo -e "${C}DONE!${NC}" +echo -e "${C}Uploading organizations table${NC}" +python3 ${DYNAMO_DUMP_LOCATION} --profile ${PROFILE} -r ${REGION} -m restore --skipThroughputUpdate -s egm-${FROM}-api_${ORGANIZATIONS_TABLE} -d egm-${TO}-api_${ORGANIZATIONS_TABLE} --dataOnly +echo -e "${C}DONE!${NC}" +echo -e "${C}Uploading speakers table${NC}" +python3 ${DYNAMO_DUMP_LOCATION} --profile ${PROFILE} -r ${REGION} -m restore --skipThroughputUpdate -s egm-${FROM}-api_${SPEAKERS_TABLE} -d egm-${TO}-api_${SPEAKERS_TABLE} --dataOnly +echo -e "${C}DONE!${NC}" +echo -e "${C}Uploading sessions table${NC}" +python3 ${DYNAMO_DUMP_LOCATION} --profile ${PROFILE} -r ${REGION} -m restore --skipThroughputUpdate -s egm-${FROM}-api_${SESSIONS_TABLE} -d egm-${TO}-api_${SESSIONS_TABLE} --dataOnly +echo -e "${C}DONE!${NC}" + + +# After moving the data you have to copy images from S3 dev to prod otherwise they are not available + +# you can download it using this command +# sudo aws s3 cp --recursive s3://egm-media/ ./imagesS3 --profile egm + +# and then you can upload it manually form the aws console. (remember we want the images and thumbnails folder) \ No newline at end of file