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 a803d717..4a69b564 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 {}; @@ -360,29 +381,55 @@ 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(); - for await (const participant of this.participantModel.find({poll: new Types.ObjectId(id)})) { - const appointments = poll.bookedEvents.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 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), + mail: {$exists: true}, + })) { + 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(), participant: participant.toObject(), - }).then(); + }).catch(console.error); } - return {...poll.toObject(), bookedEvents: events}; + return poll; } private renderEvent(event: PollEvent, locale?: string, timeZone?: string) { 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: {}, }; }; 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/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..5bb71c91 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 = { @@ -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) { @@ -103,7 +112,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..35a8d969 100644 --- a/apps/frontend/src/app/poll/table/table.component.html +++ b/apps/frontend/src/app/poll/table/table.component.html @@ -63,20 +63,34 @@ @if (participant !== editParticipant) { @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 { -
- } - } +
} @@ -135,14 +149,15 @@
} 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; } diff --git a/apps/frontend/src/app/poll/table/table.component.ts b/apps/frontend/src/app/poll/table/table.component.ts index a615ac99..64c0007e 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'); }); } @@ -116,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); + } + } } 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);