From 62e245283ce14c89d68d92e0568f874785d476bc Mon Sep 17 00:00:00 2001 From: Adrian Kunz Date: Thu, 9 May 2024 19:42:03 +0200 Subject: [PATCH 1/9] feat: Make bookedEvents a map (event id -> true OR participants) The idea is to use the value true for indicating the whole event was booked. But when booking individual participants, it can be an array of their IDs. --- apps/backend/src/poll/poll/poll.controller.ts | 26 ++++++++++++------- apps/backend/src/poll/poll/poll.service.ts | 11 +++++--- .../create-poll/create-edit-poll.component.ts | 2 +- .../src/app/poll/ical/ical.component.ts | 4 +-- .../src/app/poll/services/poll.service.ts | 4 +-- .../src/app/poll/table/table.component.html | 9 ++++--- .../src/app/poll/table/table.component.ts | 17 ++++++++---- libs/types/src/lib/schema/poll.schema.ts | 6 +++-- 8 files changed, 50 insertions(+), 29 deletions(-) diff --git a/apps/backend/src/poll/poll/poll.controller.ts b/apps/backend/src/poll/poll/poll.controller.ts index dd1122e1..17bedf57 100644 --- a/apps/backend/src/poll/poll/poll.controller.ts +++ b/apps/backend/src/poll/poll/poll.controller.ts @@ -147,13 +147,21 @@ export class PollController { return this.pollService.deleteParticipation(id, participantId); } - @Post(':id/book') - async bookEvents(@Param('id', ObjectIdPipe) id: Types.ObjectId, @Body() events: string[]): Promise { - const poll = await this.pollService.getPoll(id); - if (!poll) { - throw new NotFoundException(id); - } - - return this.pollService.bookEvents(id, events.map(e => new Types.ObjectId(e))); - } + @Post(':id/book') + async bookEvents( + @Param('id', ObjectIdPipe) id: Types.ObjectId, + @Body() events: Record, + ): Promise { + const poll = await this.pollService.getPoll(id); + if (!poll) { + throw new NotFoundException(id); + } + + // convert nested strings to ObjectIds + const bookedEvents = Object.fromEntries(Object + .entries(events) + .map(([key, value]) => [key, value === true ? true as const : value.map(v => new Types.ObjectId(v))]), + ); + return this.pollService.bookEvents(id, bookedEvents); + } } diff --git a/apps/backend/src/poll/poll/poll.service.ts b/apps/backend/src/poll/poll/poll.service.ts index 60e37de5..aa3ec946 100644 --- a/apps/backend/src/poll/poll/poll.service.ts +++ b/apps/backend/src/poll/poll/poll.service.ts @@ -360,15 +360,18 @@ export class PollService implements OnModuleInit { return this.participantModel.findByIdAndDelete(participantId, {projection: readParticipantSelect}).exec(); } - async bookEvents(id: Types.ObjectId, events: Types.ObjectId[]): Promise { + async bookEvents(id: Types.ObjectId, events: Poll['bookedEvents']): Promise { const poll = await this.pollModel.findByIdAndUpdate(id, { bookedEvents: events, }, {new: true}) - .populate<{bookedEvents: PollEvent[]}>('bookedEvents') .select(readPollSelect) .exec(); + const eventDocs = await this.pollEventModel.find({ + poll: id, + _id: {$in: Object.keys(events).map(e => new Types.ObjectId(e))}, + }); for await (const participant of this.participantModel.find({poll: new Types.ObjectId(id)})) { - const appointments = poll.bookedEvents.map(event => { + const appointments = eventDocs.map(event => { let eventLine = this.renderEvent(event, undefined, poll.timeZone); const selection = participant.selection[event._id.toString()]; if (selection === 'yes' || selection === 'maybe') { @@ -382,7 +385,7 @@ export class PollService implements OnModuleInit { participant: participant.toObject(), }).then(); } - return {...poll.toObject(), bookedEvents: events}; + return poll; } private renderEvent(event: PollEvent, locale?: string, timeZone?: string) { diff --git a/apps/frontend/src/app/poll/create-poll/create-edit-poll.component.ts b/apps/frontend/src/app/poll/create-poll/create-edit-poll.component.ts index fd793b79..d611b1ce 100644 --- a/apps/frontend/src/app/poll/create-poll/create-edit-poll.component.ts +++ b/apps/frontend/src/app/poll/create-poll/create-edit-poll.component.ts @@ -132,7 +132,7 @@ export class CreateEditPollComponent implements OnInit { adminMail: pollForm.emailUpdates ? this.poll?.adminMail || this.mail : undefined, adminPush: pollForm.pushUpdates && (this.poll?.adminPush || pushToken?.toJSON()) || undefined, timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, - bookedEvents: [], + bookedEvents: {}, settings: { deadline: deadline?.toISOString(), allowMaybe: !!pollForm.allowMaybe, diff --git a/apps/frontend/src/app/poll/ical/ical.component.ts b/apps/frontend/src/app/poll/ical/ical.component.ts index 7fbd8b99..7d922000 100644 --- a/apps/frontend/src/app/poll/ical/ical.component.ts +++ b/apps/frontend/src/app/poll/ical/ical.component.ts @@ -42,7 +42,7 @@ export class IcalComponent implements OnInit { ])), ).subscribe(([poll, events, participants]) => { this.url = new URL(`/poll/${poll.id}/participate`, window.location.origin).href; - this.config.onlyBookedEvents = poll.bookedEvents.length > 0; + this.config.onlyBookedEvents = Object.keys(poll.bookedEvents).length > 0; const exampleEvent = events.find(e => e.participants > 0) ?? events[0]; this.exampleEvent = { @@ -103,7 +103,7 @@ export class IcalComponent implements OnInit { if (!this.config.emptyEvents && !e.participants) { return false; } - if (this.config.onlyBookedEvents && !this.poll?.bookedEvents.includes(e._id)) { + if (this.config.onlyBookedEvents && !this.poll?.bookedEvents[e._id]) { return false; } return true; diff --git a/apps/frontend/src/app/poll/services/poll.service.ts b/apps/frontend/src/app/poll/services/poll.service.ts index adff9546..ed89cccd 100644 --- a/apps/frontend/src/app/poll/services/poll.service.ts +++ b/apps/frontend/src/app/poll/services/poll.service.ts @@ -4,7 +4,7 @@ import type {PollEventState} from '@apollusia/types'; import {Observable} from 'rxjs'; import {environment} from '../../../environments/environment'; -import {CreateParticipantDto, Participant, ReadPoll, ReadPollEvent, UpdateParticipantDto} from '../../model'; +import {CreateParticipantDto, Participant, Poll, ReadPoll, ReadPollEvent, UpdateParticipantDto} from '../../model'; @Injectable({ providedIn: 'root', @@ -66,7 +66,7 @@ export class PollService { return this.http.get(`${environment.backendURL}/poll/${id}/admin/${adminToken}`); } - book(id: string, events: string[]) { + book(id: string, events: Poll['bookedEvents']) { return this.http.post(`${environment.backendURL}/poll/${id}/book`, events); } diff --git a/apps/frontend/src/app/poll/table/table.component.html b/apps/frontend/src/app/poll/table/table.component.html index 7f62bb47..870a045a 100644 --- a/apps/frontend/src/app/poll/table/table.component.html +++ b/apps/frontend/src/app/poll/table/table.component.html @@ -135,14 +135,15 @@
} diff --git a/apps/frontend/src/app/poll/table/table.component.ts b/apps/frontend/src/app/poll/table/table.component.ts index a615ac99..7c011ae0 100644 --- a/apps/frontend/src/app/poll/table/table.component.ts +++ b/apps/frontend/src/app/poll/table/table.component.ts @@ -3,7 +3,7 @@ import {checkParticipant} from '@apollusia/logic'; import type {PollEventState} from '@apollusia/types'; import {ToastService} from '@mean-stream/ngbx'; -import {CreateParticipantDto, Participant, ReadPoll, ReadPollEvent, UpdateParticipantDto} from '../../model'; +import {CreateParticipantDto, Participant, Poll, ReadPoll, ReadPollEvent, UpdateParticipantDto} from '../../model'; import {PollService} from '../services/poll.service'; @Component({ @@ -22,7 +22,7 @@ export class TableComponent implements OnInit { @Output() changed = new EventEmitter(); - bookedEvents: boolean[] = []; + bookedEvents: Poll['bookedEvents'] = {}; newParticipant: CreateParticipantDto = { name: '', @@ -40,7 +40,7 @@ export class TableComponent implements OnInit { } ngOnInit() { - this.bookedEvents = this.pollEvents.map(e => this.poll.bookedEvents.includes(e._id)); + this.bookedEvents = this.poll.bookedEvents || {}; this.newParticipant.token = this.token; this.clearSelection(); this.validateNew(); @@ -106,9 +106,16 @@ export class TableComponent implements OnInit { this.pollService.selectAll(this.poll, this.pollEvents, this.newParticipant, state); } + setBooked(eventId: string, state: boolean) { + if (state) { + this.bookedEvents[eventId] = true; + } else { + delete this.bookedEvents[eventId]; + } + } + book() { - const events = this.pollEvents.filter((e, i) => this.bookedEvents[i]).map(e => e._id); - this.pollService.book(this.poll._id, events).subscribe(() => { + this.pollService.book(this.poll._id, this.bookedEvents).subscribe(() => { this.toastService.success('Booking', 'Booked events successfully'); }); } diff --git a/libs/types/src/lib/schema/poll.schema.ts b/libs/types/src/lib/schema/poll.schema.ts index e131af0d..04799f6f 100644 --- a/libs/types/src/lib/schema/poll.schema.ts +++ b/libs/types/src/lib/schema/poll.schema.ts @@ -93,8 +93,10 @@ export class Poll { @ValidateNested() settings: Settings; - @RefArray('PollEvent') - bookedEvents: Types.ObjectId[]; + @Prop({type: Object}) + @ApiProperty() + @IsObject() + bookedEvents: Record; } export const PollSchema = SchemaFactory.createForClass(Poll); From 6738c0ad7b2b1521e0f4b261652d4950b53a6ef5 Mon Sep 17 00:00:00 2001 From: Adrian Kunz Date: Fri, 10 May 2024 12:14:22 +0200 Subject: [PATCH 2/9] fix(backend): Don't send booking emails to participants without mail --- apps/backend/src/poll/poll/poll.service.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/backend/src/poll/poll/poll.service.ts b/apps/backend/src/poll/poll/poll.service.ts index aa3ec946..8b9ba3f4 100644 --- a/apps/backend/src/poll/poll/poll.service.ts +++ b/apps/backend/src/poll/poll/poll.service.ts @@ -370,7 +370,10 @@ export class PollService implements OnModuleInit { poll: id, _id: {$in: Object.keys(events).map(e => new Types.ObjectId(e))}, }); - for await (const participant of this.participantModel.find({poll: new Types.ObjectId(id)})) { + for await (const participant of this.participantModel.find({ + poll: new Types.ObjectId(id), + mail: {$exists: true}, + })) { const appointments = eventDocs.map(event => { let eventLine = this.renderEvent(event, undefined, poll.timeZone); const selection = participant.selection[event._id.toString()]; @@ -383,7 +386,7 @@ export class PollService implements OnModuleInit { appointments, poll: poll.toObject(), participant: participant.toObject(), - }).then(); + }).catch(console.error); } return poll; } From 6811880d658869b12bb090aa71413eb15741c2f2 Mon Sep 17 00:00:00 2001 From: Adrian Kunz Date: Fri, 10 May 2024 12:14:43 +0200 Subject: [PATCH 3/9] feat(frontend): Allow booking individual participants --- apps/frontend/src/app/pipes/some.pipe.ts | 4 ++-- .../frontend/src/app/poll/table/table.component.html | 12 +++++++++++- apps/frontend/src/app/poll/table/table.component.ts | 9 +++++++++ 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/apps/frontend/src/app/pipes/some.pipe.ts b/apps/frontend/src/app/pipes/some.pipe.ts index cea19834..532d537f 100644 --- a/apps/frontend/src/app/pipes/some.pipe.ts +++ b/apps/frontend/src/app/pipes/some.pipe.ts @@ -4,7 +4,7 @@ import {Pipe, PipeTransform} from '@angular/core'; name: 'some', }) export class SomePipe implements PipeTransform { - transform(array: T[], search: T): boolean { - return array.includes(search); + transform(array: T[] | unknown, search: T): boolean { + return Array.isArray(array) && array.includes(search); } } diff --git a/apps/frontend/src/app/poll/table/table.component.html b/apps/frontend/src/app/poll/table/table.component.html index 870a045a..ebbe5f98 100644 --- a/apps/frontend/src/app/poll/table/table.component.html +++ b/apps/frontend/src/app/poll/table/table.component.html @@ -77,6 +77,16 @@
} } + @if (isAdmin) { + + } } @@ -141,7 +151,7 @@
diff --git a/apps/frontend/src/app/poll/table/table.component.ts b/apps/frontend/src/app/poll/table/table.component.ts index 7c011ae0..64c0007e 100644 --- a/apps/frontend/src/app/poll/table/table.component.ts +++ b/apps/frontend/src/app/poll/table/table.component.ts @@ -123,4 +123,13 @@ export class TableComponent implements OnInit { private onChange() { this.changed.next(); } + + setBookedParticipant(eventId: string, participantId: string, state: boolean) { + const original = Array.isArray(this.bookedEvents[eventId]) ? this.bookedEvents[eventId] as string[] : []; + if (state) { + this.bookedEvents[eventId] = [...original, participantId]; + } else { + this.bookedEvents[eventId] = original.filter(id => id !== participantId); + } + } } From 53230b15b2d51ec9bf8b8478e0e7b0a5ddfcba30 Mon Sep 17 00:00:00 2001 From: Adrian Kunz Date: Fri, 10 May 2024 12:20:49 +0200 Subject: [PATCH 4/9] fix(frontend): Better individual book button layout --- apps/frontend/src/app/poll/table/table.component.html | 4 +++- apps/frontend/src/app/poll/table/table.component.scss | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/frontend/src/app/poll/table/table.component.html b/apps/frontend/src/app/poll/table/table.component.html index ebbe5f98..b9a4fff3 100644 --- a/apps/frontend/src/app/poll/table/table.component.html +++ b/apps/frontend/src/app/poll/table/table.component.html @@ -63,6 +63,7 @@ @if (participant !== editParticipant) { @for (pollEvent of pollEvents; track pollEvent; let n = $index) { +
@switch (participant.selection[pollEvent._id]) { @case ('yes') {
@@ -80,13 +81,14 @@
@if (isAdmin) { } +
} diff --git a/apps/frontend/src/app/poll/table/table.component.scss b/apps/frontend/src/app/poll/table/table.component.scss index c946d559..391f8a78 100644 --- a/apps/frontend/src/app/poll/table/table.component.scss +++ b/apps/frontend/src/app/poll/table/table.component.scss @@ -27,4 +27,5 @@ h5 { margin: 0; + flex-grow: 1; } From 1a8174180bc282082b24b1d48514dd2b567f96b5 Mon Sep 17 00:00:00 2001 From: Adrian Kunz Date: Fri, 10 May 2024 12:29:51 +0200 Subject: [PATCH 5/9] refactor(frontend): Format and add cursed variable --- .../src/app/poll/table/table.component.html | 46 ++++++++++--------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/apps/frontend/src/app/poll/table/table.component.html b/apps/frontend/src/app/poll/table/table.component.html index b9a4fff3..35a8d969 100644 --- a/apps/frontend/src/app/poll/table/table.component.html +++ b/apps/frontend/src/app/poll/table/table.component.html @@ -64,30 +64,32 @@ @for (pollEvent of pollEvents; track pollEvent; let n = $index) {
- @switch (participant.selection[pollEvent._id]) { - @case ('yes') { -
+ @switch (participant.selection[pollEvent._id]) { + @case ('yes') { +
+ } + @case ('no') { +
+ } + @case ('maybe') { +
+ } + @default { +
+ } } - @case ('no') { -
+ @if (isAdmin) { + @for (isBooked of [bookedEvents[pollEvent._id] | some:participant._id]; track isBooked // cursed_variable) { + + } } - @case ('maybe') { -
- } - @default { -
- } - } - @if (isAdmin) { - - }
} From b157293386fb460e85a58a7f7682cd4f2a5559cc Mon Sep 17 00:00:00 2001 From: Adrian Kunz Date: Fri, 10 May 2024 12:35:58 +0200 Subject: [PATCH 6/9] fix(backend): Only notify participants of appointments booked for them --- apps/backend/src/poll/poll/poll.service.ts | 36 +++++++++++++++++----- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/apps/backend/src/poll/poll/poll.service.ts b/apps/backend/src/poll/poll/poll.service.ts index 8b9ba3f4..5d1449b4 100644 --- a/apps/backend/src/poll/poll/poll.service.ts +++ b/apps/backend/src/poll/poll/poll.service.ts @@ -374,14 +374,34 @@ export class PollService implements OnModuleInit { poll: new Types.ObjectId(id), mail: {$exists: true}, })) { - const appointments = eventDocs.map(event => { - let eventLine = this.renderEvent(event, undefined, poll.timeZone); - const selection = participant.selection[event._id.toString()]; - if (selection === 'yes' || selection === 'maybe') { - eventLine += ' *'; - } - return eventLine; - }); + const appointments = eventDocs + .filter(event => { + const booked = events[event._id.toString()]; + // only show the events to the participant that are either + if (booked === true) { + // 1) booked entirely, or + return true; + } else if (Array.isArray(booked)) { + // 2) booked for the participant + return booked.some(id => participant._id.equals(id)); + } else { + return false; + } + }) + .map(event => { + let eventLine = this.renderEvent(event, undefined, poll.timeZone); + const selection = participant.selection[event._id.toString()]; + if (selection === 'yes' || selection === 'maybe') { + eventLine += ' *'; + } + return eventLine; + }); + + if (!appointments.length) { + // don't send them an email if there are no appointments + continue; + } + this.mailService.sendMail(participant.name, participant.mail, 'Poll booked', 'book', { appointments, poll: poll.toObject(), From 960017bce6490d38b40ff1c54eec2c168951c663 Mon Sep 17 00:00:00 2001 From: Adrian Kunz Date: Fri, 17 May 2024 21:58:44 +0200 Subject: [PATCH 7/9] feat(frontend): Include only booked participants in iCal export --- apps/frontend/src/app/poll/ical/ical.component.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/frontend/src/app/poll/ical/ical.component.ts b/apps/frontend/src/app/poll/ical/ical.component.ts index 7d922000..5bb71c91 100644 --- a/apps/frontend/src/app/poll/ical/ical.component.ts +++ b/apps/frontend/src/app/poll/ical/ical.component.ts @@ -67,7 +67,16 @@ export class IcalComponent implements OnInit { }); for (const event of this.getExportedEvents()) { - const eventParticipants = participants.filter(p => p.selection[event._id] === 'yes' || p.selection[event._id] === 'maybe'); + const eventParticipants = participants.filter(p => { + if (p.selection[event._id] !== 'yes' && p.selection[event._id] !== 'maybe') { + return false; + } + const bookedEvent = poll.bookedEvents[event._id]; + if (config.onlyBookedEvents && Array.isArray(bookedEvent) && !bookedEvent.includes(p._id)) { + return false; + } + return true; + }); let summary = config.customTitle || poll.title; if (eventParticipants.length === 1) { From 1e1678649338a2177537ca86e7a169ab998bb0c1 Mon Sep 17 00:00:00 2001 From: Adrian Kunz Date: Fri, 17 May 2024 22:05:42 +0200 Subject: [PATCH 8/9] test(backend): Adjust PollStub --- apps/backend/test/stubs/PollStub.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/backend/test/stubs/PollStub.ts b/apps/backend/test/stubs/PollStub.ts index 9330914d..8310a585 100644 --- a/apps/backend/test/stubs/PollStub.ts +++ b/apps/backend/test/stubs/PollStub.ts @@ -10,6 +10,6 @@ export const PollStub = (): PollDto => { showResult: ShowResultOptions.IMMEDIATELY, }, adminToken: '619b3a00-2dc3-48f1-8b3d-50386a91a559', - bookedEvents: [], + bookedEvents: {}, }; }; From c68724f058b605fb4aaf6a75f687cabb00c81a74 Mon Sep 17 00:00:00 2001 From: Adrian Kunz Date: Fri, 17 May 2024 22:14:15 +0200 Subject: [PATCH 9/9] feat(backend): Migrate legacy bookedEvents format --- apps/backend/src/poll/poll/poll.service.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/apps/backend/src/poll/poll/poll.service.ts b/apps/backend/src/poll/poll/poll.service.ts index 5d1449b4..09f42663 100644 --- a/apps/backend/src/poll/poll/poll.service.ts +++ b/apps/backend/src/poll/poll/poll.service.ts @@ -45,6 +45,7 @@ export class PollService implements OnModuleInit { this.migrateSelection(), this.migratePollEvents(), this.migrateShowResults(), + this.migrateBookedEvents(), ]); } @@ -114,6 +115,26 @@ export class PollService implements OnModuleInit { result.modifiedCount && this.logger.log(`Migrated ${result.modifiedCount} polls to the new show result format.`); } + private async migrateBookedEvents() { + // migrate all Poll's bookedEvents: ObjectId[] to Record + const polls = await this.pollModel.find({bookedEvents: {$type: 'array'}}).exec(); + if (!polls.length) { + return; + } + + for (const poll of polls) { + const bookedEvents = poll.bookedEvents as unknown as Types.ObjectId[]; + const newBookedEvents: Record = {}; + for (const event of bookedEvents) { + newBookedEvents[event.toString()] = true; + } + poll.bookedEvents = newBookedEvents; + poll.markModified('bookedEvents'); + } + await this.pollModel.bulkSave(polls, {timestamps: false}); + this.logger.log(`Migrated ${polls.length} polls to the new booked events format.`); + } + private activeFilter(active: boolean | undefined): FilterQuery { if (active === undefined) { return {};